8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 15,
|
||
"slug": "editorial-2027-08-practice-security-capstone",
|
||
"title": "SSRF начинается с URL: проверяем адрес до сетевого вызова",
|
||
"excerpt": "Практическая защита server-side запроса: разбираем URL, применяем точный allowlist, запрещаем обход через credentials и редиректы, а затем ограничиваем сам сетевой вызов.",
|
||
"contentHtml": "<p>Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит <code>https://cdn.example.test/avatar.jpg</code>, но тот же endpoint может получить <code>https://cdn.example.test@127.0.0.1/admin</code> или адрес внутреннего metadata-сервиса. Проверка префикса <code>https://cdn.example.test</code> видит доверенное начало и пропускает запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.</p>\n<p>Защита начинается до сетевого вызова: парсер строит структуру URL, политика проверяет схему, credentials, hostname и порт, а сетевой слой ограничивает фактический egress. Учебный валидатор ниже возвращает решение без DNS и HTTP. Далее отдельно разберём redirect, адреса A/AAAA и лимиты ответа, которые нельзя спрятать за одной функцией.</p>\n<h2>Как возникает ошибка</h2>\n<p>URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа <code>@</code> может выглядеть как доверенное имя, но фактический host находится после него. В адресе <code>https://cdn.example.test@127.0.0.1/file</code> значение <code>url.hostname</code> — <code>127.0.0.1</code>. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.</p>\n<p>Первый слой принимает только нужную схему. Для загрузки изображения это обычно <code>https:</code>. Второй слой запрещает <code>username</code> и <code>password</code>. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.</p>\n<div class=\"table-scroll\"><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>«Доверенный» URL обращается к loopback</td><td>Проверяли строковый префикс или часть до <code>@</code></td><td>Распарсить URL и вывести только hostname</td><td>Запретить credentials и сравнивать фактический host</td></tr><tr><td>Проходит похожий домен</td><td>Использовали <code>endsWith</code> без границы имени</td><td>Проверить <code>evil-example.test</code> и <code>sub.example.test</code></td><td>Разрешать точное имя или явный суффикс <code>.example.test</code></td></tr><tr><td>Запрос уходит на другой адрес после 302</td><td>Клиент автоматически следует redirect</td><td>Перехватить заголовок <code>Location</code></td><td>Запретить redirect или повторить политику для каждого нового URL</td></tr><tr><td>Имя разрешено, IP закрытый</td><td>Проверен hostname, но не результат DNS</td><td>Проверить A и AAAA и диапазоны адресов</td><td>Сверить адреса с политикой и контролировать egress</td></tr><tr><td>Разрешённый ответ занимает память</td><td>Есть allowlist, но нет лимита тела</td><td>Проверить Content-Length и поток чтения</td><td>Остановить чтение после заданного размера и ограничить timeout</td></tr></tbody></table></div>\n<figure><img src=\"/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg\" alt=\"Проверка URL перед сетевым вызовом: разбор, схема, hostname, порт и credentials, затем разрешение запроса\" loading=\"lazy\" /><figcaption>Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.</figcaption></figure>\n<h2>Allowlist должен описывать ресурс</h2>\n<p>Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только <code>cdn.example.test</code>. Если нужны поддомены, правило должно различать границу: имя равно <code>example.test</code> или заканчивается на <code>.example.test</code>. Условие <code>hostname.endsWith('example.test')</code> пропустит <code>evil-example.test</code>. Значит, правило должно описывать владение именами, а не похожесть строки. WHATWG URL Standard отдельно различает <code>example.test</code> и <code>example.test.</code>; не удаляйте завершающую точку молча: выберите каноническую форму конфигурации и закрепите её тестом.</p>\n<p>Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.</p>\n<p>Порт нужно ограничить отдельно. Явный <code>:8443</code> — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.</p>\n<h2>Учебная проверка без запроса</h2>\n<p>Функция принимает строку URL и массив разрешённых имён. Сначала <code>new URL</code> строит разобранный URL, затем конфигурация один раз приводится к нижнему регистру и сравнивается с <code>url.hostname</code>. Результат имеет форму <code>{ allowed, reason, href }</code>. Код не вызывает <code>fetch</code>, не разрешает DNS и не проверяет корпоративный proxy; локальные кейсы ниже проверяют порядок решений, но не доказывают безопасность DNS, proxy или реальной сети. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.</p>\n<pre><code>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)</code></pre>\n<p>Запуск печатает только имя кейса и решение. <code>default-port</code> показывает, что явный порт 443 после разбора совпадает с обычным HTTPS-адресом; <code>credentials</code> останавливает userinfo до проверки host; <code>loopback</code> и <code>similar-host</code> не проходят точный allowlist. Неверная схема, нестандартный порт и невалидная строка получают отдельные причины.</p>\n<p>Это регрессионный тест парсера и политики, а не сетевой тест. <code>allowed: true</code> означает только, что разобранные поля совпали с конфигурацией. Перед передачей <code>href</code> клиенту задайте запрет автоматических redirect, timeout и лимит тела; DNS и egress проверяйте на отдельном слое. Полный входной URL не включайте в лог отказа: в нём могут быть пароль, token или query с персональными данными.</p>\n<h2>Редирект меняет цель</h2>\n<p>Даже разрешённый CDN может ответить 301 или 302 с новым <code>Location</code>. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом <code>redirect-not-allowed</code>. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.</p>\n<p>Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный <code>Location</code> безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.</p>\n<h2>DNS и сетевой слой</h2>\n<p>Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA там, где это важно для модели угроз.</p>\n<p>В чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.</p>\n<p>Смена DNS-ответа между проверкой и соединением создаёт отдельную гонку. В контексте SSRF OWASP описывает DNS pinning и рекомендует мониторить, во что разрешённые имена превращаются по A и AAAA. Не называйте одну функцию защитой от DNS-перепривязки. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.</p>\n<h2>Ограничения сетевого вызова</h2>\n<p>Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.</p>\n<p>Не принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.</li><li>Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.</li><li>Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.</li><li>Добавьте отрицательные тесты для <code>@</code>, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.</li><li>Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.</li><li>Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.</li><li>Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.</li><li>Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.</li><li>Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.</p>\n<p>Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP: Server Side Request Forgery Prevention Cheat Sheet</a> — allowlist, запрет автоматических redirect, проверка IPv4/IPv6 и мониторинг DNS. Граница применимости: документ не проверяет конфигурацию конкретной сети, proxy или runtime.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc3986.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 3986: Uniform Resource Identifier</a> — синтаксис authority, userinfo, host и port. Граница применимости: синтаксис URI не является моделью угроз и не разрешает сетевой доступ.</li><li><a href=\"https://url.spec.whatwg.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG URL Standard</a> — алгоритм разбора URL, представление hostname, credentials, default port и завершающей точки домена. Граница применимости: парсер нормализует адрес, но не решает, какие хосты и IP разрешены приложению.</li></ul>"
|
||
}
|