{ "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 видит доверенное начало и пропускает запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Защита начинается до сетевого вызова: парсер строит структуру URL, политика проверяет схему, credentials, hostname и порт, а сетевой слой ограничивает фактический egress. Учебный валидатор ниже возвращает решение без DNS и HTTP. Далее отдельно разберём redirect, адреса A/AAAA и лимиты ответа, которые нельзя спрятать за одной функцией.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» 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
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Значит, правило должно описывать владение именами, а не похожесть строки. WHATWG URL Standard отдельно различает example.test и example.test.; не удаляйте завершающую точку молча: выберите каноническую форму конфигурации и закрепите её тестом.

\n

Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку URL и массив разрешённых имён. Сначала new URL строит разобранный URL, затем конфигурация один раз приводится к нижнему регистру и сравнивается с url.hostname. Результат имеет форму { allowed, reason, href }. Код не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy; локальные кейсы ниже проверяют порядок решений, но не доказывают безопасность DNS, proxy или реальной сети. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
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. Неверная схема, нестандартный порт и невалидная строка получают отдельные причины.

\n

Это регрессионный тест парсера и политики, а не сетевой тест. allowed: true означает только, что разобранные поля совпали с конфигурацией. Перед передачей href клиенту задайте запрет автоматических redirect, timeout и лимит тела; DNS и egress проверяйте на отдельном слое. Полный входной URL не включайте в лог отказа: в нём могут быть пароль, token или query с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному 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-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

Не принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" }