{ "index": 15, "slug": "editorial-2027-08-practice-security-capstone", "title": "SSRF начинается с URL: проверяем адрес до сетевого вызова", "excerpt": "Практическая защита server-side запроса: разбираем URL, применяем точный allowlist, запрещаем обход через credentials и редиректы, а затем ограничиваем сам сетевой вызов.", "contentHtml": "
Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Проверка префикса https://cdn.example.test видит доверенное начало и пропускает запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.
Защита начинается до сетевого вызова: парсер строит структуру URL, политика проверяет схему, credentials, hostname и порт, а сетевой слой ограничивает фактический egress. Учебный валидатор ниже возвращает решение без DNS и HTTP. Далее отдельно разберём redirect, адреса A/AAAA и лимиты ответа, которые нельзя спрятать за одной функцией.
\nURL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.
Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| «Доверенный» URL обращается к loopback | Проверяли строковый префикс или часть до @ | Распарсить URL и вывести только hostname | Запретить credentials и сравнивать фактический host |
| Проходит похожий домен | Использовали endsWith без границы имени | Проверить evil-example.test и sub.example.test | Разрешать точное имя или явный суффикс .example.test |
| Запрос уходит на другой адрес после 302 | Клиент автоматически следует redirect | Перехватить заголовок Location | Запретить redirect или повторить политику для каждого нового URL |
| Имя разрешено, IP закрытый | Проверен hostname, но не результат DNS | Проверить A и AAAA и диапазоны адресов | Сверить адреса с политикой и контролировать egress |
| Разрешённый ответ занимает память | Есть allowlist, но нет лимита тела | Проверить Content-Length и поток чтения | Остановить чтение после заданного размера и ограничить timeout |
Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Значит, правило должно описывать владение именами, а не похожесть строки. WHATWG URL Standard отдельно различает example.test и example.test.; не удаляйте завершающую точку молча: выберите каноническую форму конфигурации и закрепите её тестом.
Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.
\nПорт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.
Функция принимает строку URL и массив разрешённых имён. Сначала new URL строит разобранный URL, затем конфигурация один раз приводится к нижнему регистру и сравнивается с url.hostname. Результат имеет форму { allowed, reason, href }. Код не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy; локальные кейсы ниже проверяют порядок решений, но не доказывают безопасность DNS, proxy или реальной сети. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.
function validateRemoteUrl(value, allowedHosts) {\n let url;\n try {\n url = new URL(value);\n } catch {\n return { allowed: false, reason: 'invalid-url' };\n }\n\n const allowlist = new Set(\n allowedHosts.map((host) => host.toLowerCase()),\n );\n\n if (url.protocol !== 'https:') {\n return { allowed: false, reason: 'scheme' };\n }\n if (url.username || url.password) {\n return { allowed: false, reason: 'credentials' };\n }\n if (url.port && url.port !== '443') {\n return { allowed: false, reason: 'port' };\n }\n if (!url.hostname || !allowlist.has(url.hostname)) {\n return { allowed: false, reason: 'host' };\n }\n\n return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nconst allowlist = ['cdn.example.test'];\nconst cases = [\n ['valid', 'https://cdn.example.test/file.jpg', true, 'allowlist'],\n ['default-port', 'https://cdn.example.test:443/file.jpg', true, 'allowlist'],\n ['credentials', 'https://cdn.example.test@127.0.0.1/file.jpg', false, 'credentials'],\n ['loopback', 'https://127.0.0.1/file.jpg', false, 'host'],\n ['similar-host', 'https://evil-example.test/file.jpg', false, 'host'],\n ['wrong-scheme', 'http://cdn.example.test/file.jpg', false, 'scheme'],\n ['unexpected-port', 'https://cdn.example.test:8443/file.jpg', false, 'port'],\n ['invalid-url', 'not-a-url', false, 'invalid-url'],\n];\n\nfor (const [name, value, expectedAllowed, expectedReason] of cases) {\n const result = validateRemoteUrl(value, allowlist);\n if (\n result.allowed !== expectedAllowed\n || result.reason !== expectedReason\n ) {\n throw new Error(name + ': unexpected policy result');\n }\n console.log(\n name + ': ' + (result.allowed ? 'allow' : 'deny')\n + ' (' + result.reason + ')',\n );\n}\n// valid: allow (allowlist)\n// default-port: allow (allowlist)\n// credentials: deny (credentials)\n// loopback: deny (host)\n// similar-host: deny (host)\n// wrong-scheme: deny (scheme)\n// unexpected-port: deny (port)\n// invalid-url: deny (invalid-url)\nЗапуск печатает только имя кейса и решение. default-port показывает, что явный порт 443 после разбора совпадает с обычным HTTPS-адресом; credentials останавливает userinfo до проверки host; loopback и similar-host не проходят точный allowlist. Неверная схема, нестандартный порт и невалидная строка получают отдельные причины.
Это регрессионный тест парсера и политики, а не сетевой тест. allowed: true означает только, что разобранные поля совпали с конфигурацией. Перед передачей href клиенту задайте запрет автоматических redirect, timeout и лимит тела; DNS и egress проверяйте на отдельном слое. Полный входной URL не включайте в лог отказа: в нём могут быть пароль, token или query с персональными данными.
Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.
Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.
Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA там, где это важно для модели угроз.
\nВ чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.
\nСмена DNS-ответа между проверкой и соединением создаёт отдельную гонку. В контексте SSRF OWASP описывает DNS pinning и рекомендует мониторить, во что разрешённые имена превращаются по A и AAAA. Не называйте одну функцию защитой от DNS-перепривязки. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.
\nAllowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.
\nНе принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.
\n@, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.
\nEndpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.
\n