# Тесты SSRF для инструментов агентов, которые добавляют учетные данные

Инструмент агента, который добавляет учетные данные, превращает обычный исходящий HTTP-запрос в решение о границе доверия. Если инструмент принимает адрес от агента, проверяет приятное на вид имя хоста, а затем позволяет HTTP-клиенту идти туда, куда приведут DNS и редиректы, учетные данные окажутся у выбранного атакующим сервиса.

Исправление не сводится к длинному списку запретов, вставленному в валидатор URL. Нужна матрица назначений, которая проверяет весь жизненный цикл запроса: разбор, разрешение DNS, выбор адреса, подключение, обработку редиректов и только после этого добавление учетных данных. Тест должен видеть узел, который получил запрос, потому что именно туда ушел секрет.

## Учетные данные нужно добавлять после проверки назначения

Инструмент должен решить, можно ли обращаться к адресу, до добавления любой аутентификационной информации. Это касается bearer-токенов, базовых учетных данных, пользовательских заголовков, подписей запросов, клиентских сертификатов и SSH-идентификаторов.

Команды часто ставят проверку не там, где нужно. Они проверяют исходную строку URL, создают запрос с заголовком Authorization и вызывают удобный HTTP-помощник с включенным переходом по редиректам. Затем помощник разрешает имена, выбирает IPv6 или IPv4, следует заголовку Location и может повторно использовать заголовки или состояние соединения так, что код проверки этого уже не видит.

У такого дизайна есть одно достоинство: его легко написать. Но опасное решение принимает компонент, который не знает, несет ли запрос учетные данные.

Разделяйте два решения:

- Приемлем ли этот URL для инструмента с точки зрения синтаксиса?
- Разрешено ли это конкретное сетевое назначение для аутентифицированного соединения?

Первое решение разбирает схему, хост, порт, путь, userinfo и процентное кодирование. Второе классифицирует разрешенные адреса и управляет фактическим подключением. Список разрешенных хостов может помогать в обоих решениях, но не заменяет второе.

RFC 3986 ясно показывает проблему разбора. Компонент authority имеет форму `[userinfo@]host[:port]`; строка, в которой перед символом `@` визуально находится доверенное имя хоста, все равно может указывать на IP-адрес после него. RFC 3986 даже использует такую конструкцию в обсуждении вводящих в заблуждение строк authority URI. Не разбирайте URL вручную и не ищите доверенное имя в исходной строке. Используйте один парсер, учитывающий стандарты, запрещайте userinfo в адресах назначения, если он не нужен инструменту по легитимной причине, и обращайтесь к полю хоста, полученному после разбора.

Инвариант безопасности достаточно прост, чтобы сделать его проверяемым:

> Инструмент не должен отправлять ни одного аутентифицированного байта, пока не одобрит подключенный узел и authority запроса.

«Аутентифицированный байт» включает строку запроса или заголовок Host только тогда, когда сами эти значения раскрывают секрет. В большинстве HTTP-сценариев жесткая граница проходит по заголовку с учетными данными или подписанной полезной нагрузке. Будьте строже, если путь, строка запроса или тело содержат capability URL, идентификатор клиента или другое чувствительное значение.

## Сначала определите точку принятия решения

Нельзя протестировать защиту от SSRF, пока не названа точная точка, где она разрешает или запрещает запрос. Формулировка «до запроса» слишком расплывчата. У запроса есть несколько моментов, когда назначение может измениться.

Используйте такую последовательность как модель для HTTP-инструмента, добавляющего учетные данные:

1. Разберите переданный абсолютный URL и отклоните неподдерживаемые схемы, некорректный authority, userinfo и порты, которые не входят в контракт инструмента.
2. Разрешите имя хоста через тот резолвер, которым реально пользуется процесс. Получите ответы A и AAAA, а не только тот ответ, который предпочла машина разработчика.
3. Классифицируйте каждый возможный адрес. Отклоните запрос, если среди них есть адрес вне разрешенных классов, если только коннектор не умеет зафиксировать подключение на одобренном адресе.
4. Откройте соединение с разрешенным адресом, затем проверьте адрес удаленного узла до записи учетных данных.
5. Создайте аутентифицированный запрос только после этой проверки. Явно задайте нужный заголовок Host и имя сервера TLS.
6. Считайте редирект новым запросом, который начинается с разбора URL, а не продолжением с унаследованным доверием.

Это сложнее, чем проверка `hostname != "localhost"`. Так и должно быть. RFC 8305 описывает клиентов, которые почти одновременно выполняют запросы AAAA и A и пробуют адреса с учетом предпочтения IPv6. Набор тестов, разрешающий только записи A, может подтвердить код, который сломается при появлении доступной записи AAAA.

Точка принятия решения определяет и то, что должен записывать тестовый стенд. Для каждой попытки сохраняйте:

- URL, который получил инструмент;
- ответы DNS, возвращенные инструменту;
- адрес, выбранный для подключения;
- адрес, который увидел принимающий сервер;
- все заголовки запроса, скрывая тестовые учетные данные в отчетах.

Не используйте тест, который проверяет только код состояния или возникшее исключение. Ответ 403 от сервера-перехватчика доказывает, что он получил запрос. Тайм-аут может не доказывать ничего. Нужное утверждение звучит так: «Заблокированный слушатель не увидел ни одного запроса с учетными данными».

## Стройте матрицу вокруг адресов назначения, а не имен хостов

Матрица назначений это долговечный артефакт для такой работы. Каждая строка содержит форму входных данных, ответ резолвера, возможное поведение редиректа и ожидаемый результат. Она не дает тестам расползаться, когда один разработчик проверяет `127.0.0.1`, другой `10.0.0.1`, а странные формы, которые принимает среда выполнения, не проверяет никто.

Начните с небольшой матрицы с однозначным ожидаемым результатом. В примере ниже `public.test` указывает на слушатель тестового стенда, классифицированный как разрешенный, а любой другой класс адресов должен быть запрещен до добавления учетных данных.

| Случай | Переданный URL | Поведение DNS или редиректа | Ожидаемый результат |
| --- | --- | --- | --- |
| Публичный IPv4 | `https://public.test/echo` | Запись A указывает на публичный стенд | Разрешить и отправить тестовые учетные данные |
| Loopback IPv4 | `http://127.0.0.1:18080/` | Адрес указан напрямую | Запретить до подключения |
| Вариант loopback IPv4 | `http://127.1:18080/` | Сокращенная форма, зависящая от парсера | Отклонить или классифицировать как loopback, никогда не разрешать |
| Частный RFC 1918 | `http://10.20.30.40/` | Адрес указан напрямую | Запретить до подключения |
| Частный RFC 1918 | `http://172.20.30.40/` | Адрес указан напрямую | Запретить до подключения |
| Частный RFC 1918 | `http://192.168.20.40/` | Адрес указан напрямую | Запретить до подключения |
| Loopback IPv6 | `http://[::1]:18080/` | Адрес указан напрямую | Запретить до подключения |
| Link-local IPv6 | `http://[fe80::1]/` | Адрес указан напрямую | Запретить до подключения |
| IPv4 в отображении IPv6 | `http://[::ffff:127.0.0.1]/` | Адрес указан напрямую | Запретить до подключения |
| Смешанный ответ DNS | `https://mixed.test/` | Разрешенный A, заблокированный AAAA | Запретить или зафиксировать подключение на разрешенном адресе |
| Имя с rebinding | `https://rebind.test/` | Сначала разрешенный адрес, затем заблокированный | Запретить аутентифицированный запрос к заблокированному узлу |
| Редирект на loopback | `https://public.test/to-local` | 302 на `http://127.0.0.1:18080/` | Запретить второй запрос |
| Редирект на частный DNS | `https://public.test/to-private` | 302 на `https://internal.test/` | Снова разрешить имя и запретить второй запрос |
| Обман через userinfo | `http://public.test@127.0.0.1/` | Фактический хост находится после `@` | Отклонить до подключения |

RFC 1918 определяет только три частных блока IPv4: 10/8, 172.16/12 и 192.168/16. Проверяйте границы, особенно `172.15.255.255`, `172.16.0.0`, `172.31.255.255` и `172.32.0.0`. Небрежная проверка префикса часто блокирует весь диапазон 172/8, ломая легитимные публичные адреса, или блокирует только 172.16/16, пропуская большую часть выделенного частного диапазона.

Не принимайте эту таблицу за универсальную производственную политику. Некоторым внутренним инструментам действительно нужно обращаться к конкретному частному сервису. Если это требование продукта, создайте для сервиса отдельное правило с узким именем хоста, портом и проверкой идентичности. Не делайте правило «в нашей офисной сети частные адреса допустимы» настройкой по умолчанию для любого назначения, выбранного агентом.

## Тесты разбора URL выявляют ошибки раньше DNS

Парсер должен определить тот хост, который будет использовать коннектор. Это кажется очевидным, пока тест не обнаружит, что один компонент нормализует входные данные иначе, чем другой.

Добавьте следующие случаи в отдельный набор тестов парсера. Они должны выполняться без DNS и сетевого сокета:

```text
http://public.test@127.0.0.1/admin
http://127.0.0.1.nip.example/
http://[::1]/
http://[::ffff:7f00:1]/
http://0177.0.0.1/
http://2130706433/
http://127.0.0.1%2f.example/
http://public.test:80@127.0.0.1/
http://public.test./
```

Ожидаемый результат не всегда сводится к правилу «эта точная строка должна распознаться как loopback». Библиотеки URI и URL по-разному обрабатывают старые числовые формы IPv4, идентификаторы зон и некорректное процентное кодирование. Тест должен формулировать свойство безопасности: если среда выполнения принимает входные данные и подключается по ним к заблокированному адресу, инструмент обязан отклонить запрос. Если среда выполнения сама отклоняет входные данные, это тоже приемлемо.

Это важно, потому что многие команды сначала пишут собственный фильтр хоста, а затем передают неизмененный URL другой библиотеке. Фильтр может считать `2130706433` неизвестным зарегистрированным именем, тогда как коннектор интерпретирует его как 127.0.0.1. Так вы протестировали два парсера и доверились более безопасному результату.

Используйте одно и то же разобранное представление для проверки и подключения. Если это невозможно, пусть конечный коннектор сообщает выбранный числовой узел, а затем выполняйте последнюю проверку класса адреса до того, как учетные данные покинут процесс.

Проверяйте и нормализацию имен хостов. Конечная точка в `public.test.` в обычном DNS указывает на то же имя, что и `public.test`, но простые списки разрешенных имен часто сравнивают исходные строки. Еще один источник расхождений это интернационализированные имена. Один раз преобразуйте имя к каноническому представлению среды выполнения, затем сопоставляйте DNS-имя по границам меток. Проверка суффикса вроде `endsWith("trusted.example")` принимает `untrusted.example` и `trusted.example.attacker.test`; ни один из них не является доверенным поддоменом.

## Тесты DNS rebinding должны принудительно выполнять второй запрос

DNS rebinding это ошибка, связанная со временем. Первоначальный запрос возвращает приемлемый адрес. Следующий запрос к тому же имени возвращает loopback, частный адрес или другую заблокированную цель. Если проверка и подключение используют разные результаты разрешения, атакующий может пройти первую проверку и направить вторую операцию в другое место.

Для полезного стенда нужен небольшой управляемый авторитетный DNS-сервер и два HTTP-слушателя. Один слушатель это разрешенный стенд. Другой это заблокированный стенд, который записывает, получил ли он тестовый заголовок Authorization. Задайте DNS-серверу расписание ответов для `rebind.test`:

```text
query 1: rebind.test. A     198.51.100.20
query 2: rebind.test. A     127.0.0.1
query 1: rebind.test. AAAA  2001:db8::20
query 2: rebind.test. AAAA  ::1
```

Используйте адреса только для тестового стенда и при необходимости настройте разрешенный слушатель в тестовой сети. Публично выглядящие адреса документации в этом примере это обозначения стенда, а не конечные точки, к которым тест должен обращаться через Интернет.

Запустите два варианта. В первом заставьте инструмент выполнить собственную проверку DNS, а затем вызовите HTTP-библиотеку, которая разрешает имя отдельно. Если в реализации есть искомый разрыв, заблокированный слушатель должен получить учетные данные. Во втором настройте путь подключения на использование одобренного адреса, сохраните исходное имя только для проверки Host и имени TLS и убедитесь, что заблокированный слушатель ничего не увидел.

Кэширование может скрыть проблему. Кэш резолвера, пул соединений или кэш операционной системы способен превратить второй ответ в несущественное событие. Тестовый стенд должен управлять кэшем, закрывать простаивающие соединения между запусками, при необходимости использовать новое имя для каждого запуска и записывать каждый DNS-запрос. Если тест rebinding проходит без подтверждения двух разных запросов, он не тестировал rebinding.

Не пытайтесь решить проблему доверием к TTL DNS. TTL это подсказка для кэширования, а не обещание, что имя останется безопасным между проверкой и подключением. Код должен передавать одобренный адрес в соединение или проверять адрес узла после открытия сокета.

## Редиректы требуют отдельных решений о назначении

Редирект меняет назначение. Он может изменить схему, хост, порт, путь или все четыре компонента. Если считать его продолжением доверенного запроса, безопасный на вид публичный URL легко превращается в обращение к `127.0.0.1`, сервису метаданных экземпляра, маршрутизатору или внутренней плоскости управления.

Отключите автоматический переход по редиректам в транспорте, который выполняет запрос с учетными данными. Прочитайте ответ с редиректом, установите небольшой лимит переходов, разберите значение Location относительно текущего URL по правилам библиотеки и снова выполните полную проверку назначения.

Стенд редиректов должен включать как минимум такие сценарии:

- Публичный URL возвращает 302 на литеральный loopback IPv4.
- Публичный URL возвращает 307 на имя хоста, которое разрешается в частный адрес IPv4.
- Публичный URL возвращает относительный Location вроде `/next`, который после обычного разрешения должен остаться в уже одобренном authority.
- Публичный URL возвращает Location относительно схемы, например `//other.test/path`, что меняет authority и требует полной проверки.
- Публичный URL возвращает цепочку, где первый переход разрешен, а более поздний заблокирован.

Коды состояния имеют значение. 301, 302 и 303 могут заставить клиента заменить нестандартный метод на GET. 307 и 308 предназначены для сохранения метода и тела. Не полагайтесь на поведение библиотеки по умолчанию, если речь идет о подписанном POST или записи с bearer-аутентификацией. На каждом стенде редиректа записывайте метод, хеш тела, заголовок Host и заголовок Authorization.

Удалять Authorization при смене authority это хорошая защита, но она не отменяет проверку назначения. Запрос без Authorization все равно может содержать подписанный URL в пути, клиентский сертификат, cookies или чувствительное тело. Редирект на тот же хост также может вести с обычного пути на локальную службу, если между переходами изменился DNS.

Здесь тест должен быть намеренно простым. Пусть заблокированный слушатель редиректа возвращает 200. Если проверять только ошибку клиента, клиент, следующий по редиректу, может успешно выполнить SSRF-запрос, а тест ошибочно сочтет это успехом. Проверяйте число перехваченных запросов и наличие учетных данных на слушателе.

## IPv6 это полноценная поверхность SSRF

IPv6 обычно забывают случайно. Разработчик проверяет `127.0.0.1`, блокирует диапазоны RFC 1918 и выпускает код, который считает `[::1]` экзотическим именем хоста. Современные клиенты могут запрашивать записи AAAA одновременно с A, а маршрут IPv6 может победить, даже если тесты IPv4 проходят.

RFC 4291 определяет `::1/128` как IPv6 loopback, а `::/128` как неопределенный адрес. Он также определяет link-local unicast-адреса в диапазоне `fe80::/10`. Такие классы адресов не должны случайно становиться аутентифицированным назначением, выбранным агентом.

Проверяйте эти категории явно:

| Класс адреса | Пример | Ожидаемый результат по умолчанию |
| --- | --- | --- |
| Неопределенный | `[::]` | Запретить |
| Loopback | `[::1]` | Запретить |
| Link-local | `[fe80::1]` | Запретить |
| Уникальный локальный | `[fc00::1]`, `[fd12:3456::1]` | Запретить, если явно не разрешен |
| Multicast | `[ff02::1]` | Запретить |
| IPv4 в отображении IPv6, loopback | `[::ffff:127.0.0.1]` | Запретить |
| IPv4 в отображении IPv6, частный | `[::ffff:192.168.1.10]` | Запретить |

Классификация должна работать с двоичным адресом, а не с его написанием. Один и тот же адрес может быть записан в сокращенной или полной форме IPv6. Сравнение строк с `::1` пропустит `0:0:0:0:0:0:0:1`. Сначала разберите адрес в настоящий тип IP. Это непримечательная, но важная часть защиты от такого класса ошибок.

Для идентификаторов области видимости тоже нужно четкое правило. Вход вроде `[fe80::1%25en0]` содержит идентификатор интерфейса после процентного кодирования. Большинству инструментов следует отклонять адреса с областью видимости, полученные от агента, вместо попытки решить, какой локальный интерфейс безопасен. Если у продукта есть обоснованный сценарий работы в локальной сети, выбор интерфейса должен быть явной настройкой владельца, а не деталью URL, переданной агентом.

Для смешанных ответов A и AAAA требуется однозначное решение. Если резолвер возвращает один разрешенный IPv4 и один заблокированный IPv6, безопаснее всего отказать. Если ради доступности нужно использовать разрешенный вариант, коннектор должен зафиксировать сокет на этом адресе и больше не передавать имя хоста резолверу. Правило «мы предпочитаем IPv4» не является контролем. Библиотеки сети, операционные системы и гонки подключений могут выбрать другой адрес.

## Запускайте матрицу в закрытом тестовом стенде

Не направляйте SSRF-тесты на настоящие локальные службы ноутбука или конечную точку облачных метаданных. Вам нужны доказательства, а не тяжелый день. Поместите резолвер и все слушатели в изолированную тестовую сеть, используйте одноразовые учетные данные и пусть слушатели записывают собственный адрес и полученные заголовки.

Простой стенд состоит из четырех компонентов:

1. Тестовый раннер, который вызывает инструмент с URL и ссылкой на одноразовые учетные данные.
2. Управляемый DNS-сервер, способный возвращать ответы A и AAAA в заданном порядке.
3. Разрешенный HTTP-стенд, который записывает успешные аутентифицированные запросы.
4. Заблокированный HTTP-стенд, который считает любое подключение или запрос ошибкой теста.

Пусть заблокированный стенд возвращает тело ответа, по которому утечку легко заметить в локальных логах, но никогда не печатает полный токен. Например, он может вернуть адрес узла, метод запроса, заголовок Host и признак наличия `Authorization`. Раннер сравнивает эти данные с ожидаемой строкой матрицы.

Формат результата должен быть достаточно простым для просмотра в CI:

```text
case=redirect_to_loopback
submitted=https://public.test/to-local
resolved=198.51.100.20
redirect=http://127.0.0.1:18080/
decision=deny
allowed_requests=0
blocked_connections=0
blocked_credentials=0
```

При ошибке сохраняйте те же поля и фактический узел. Это превращает расплывчатый регресс в понятный отчет:

```text
case=rebind_ipv6
submitted=https://rebind.test/export
validation_answer=2001:db8::20
connected_peer=::1
blocked_credentials=1
result=FAIL
```

Такой отчет сразу показывает ошибку: код одобрил один ответ DNS, а подключился к другому адресу. Он также сообщает, пересекли ли учетные данные границу, то есть показывает именно тот риск, который нужно контролировать.

Если инструмент поддерживает настройку прокси, добавьте в матрицу строки для прокси. HTTP-прокси меняет направление TCP-соединения, а CONNECT может заставить прокси обратиться к цели, выбранной атакующим. Решите, является ли сам прокси доверенным транспортом и проверяет ли инструмент конечный authority. Тест, который проверяет только адрес прокси, может подтвердить работу открытого ретранслятора для внутренних назначений.

## Разделяйте authority и адрес назначения в реализации

TLS легко запутывает эту проблему. Для безопасного подключения к имени хоста инструмент может открыть сокет к одобренному числовому адресу, одновременно передав исходное разрешенное имя как имя сервера TLS и заголовок Host HTTP. Это разные поля с разными задачами.

Числовой узел отвечает на вопрос «Куда направляется этот сокет?». Имя сервера TLS отвечает на вопрос «Какую идентичность сертификата должен подтвердить сервер?». Заголовок Host отвечает на вопрос «К какому HTTP authority обращается этот запрос?». Реализация должна явно хранить все три значения, а не позволять удобному HTTP API выводить их из изменяемой строки URL.

Это разделение также показывает ошибочность популярного совета: один раз разрешить имя, а затем заменить его в URL числовым адресом. Такой прием может избежать второго DNS-запроса, но нарушить проверку сертификата TLS, виртуальный хостинг и схемы подписанных запросов. Разработчики часто отвечают отключением проверки сертификатов или ослаблением проверки хоста. Это хуже исходной ошибки.

Вместо этого используйте транспорт, который умеет подключаться к одобренному адресу, сохраняя строгую проверку TLS для исходного имени. После рукопожатия убедитесь, что адрес узла сокета совпадает с одобренным. Если среда выполнения не дает такого контроля, не утверждайте, что она предотвращает DNS rebinding для аутентифицированных запросов агента. Зафиксируйте ограничение в границе продукта и не добавляйте учетные данные к назначениям, выбранным агентом.

У SSH та же структура, хотя он не использует HTTP-редиректы. Разрешите и классифицируйте запрошенный хост, зафиксируйте подключение на одобренном адресе и проверьте ключ хоста относительно нужной идентичности. Запрос ключа хоста или слишком разрешающее правило known-hosts не заменяет политику назначения.

Sallyport хранит учетные данные внутри зашифрованного хранилища и сам выполняет действия HTTP и SSH, а не передает секретный материал агенту. Эта граница работает только в том случае, если слой действий применяет ограничения назначения до использования сохраненных учетных данных.

## Сделайте матрицу условием выпуска, а не документом по безопасности

Матрица назначений полезна только тогда, когда запускается на том же пути запросов, который попадет в релиз. Unit-тест для `isPrivateIp()` не проверяет резолвер HTTP-клиента, код редиректов, работу прокси или пул соединений. Оставьте unit-тесты, но сделайте тесты стенда обязательными при обновлении транспортной библиотеки, парсера URL, конфигурации резолвера или кода добавления учетных данных.

Добавляйте строки после исправления ошибок. Не сводите неприятный инцидент к общей формулировке вроде «улучшить проверку SSRF». Сохраняйте точные входные данные, последовательность DNS, ответ редиректа и ожидаемое отсутствие учетных данных. Будущим сопровождающим нужен этот опыт в исполняемом виде.

Разбирайте ошибки по категориям. Расхождения парсеров указывают на дублирование логики работы с URL. Если заблокированный слушатель видит TCP-подключение, но не запрос, возможно, проверка выполняется слишком поздно, даже если токен не утек. Если заблокированный слушатель видит заголовок Authorization, инвариант нарушен, и выпуск нужно остановить.

Эта работа меняет вопрос, который задает команда. Перестаньте спрашивать, выглядело ли имя хоста внешним. Спрашивайте, какой узел получил аутентифицированный запрос, как он был выбран и может ли тест доказать ответ. Если вывод теста не дает такой информации, в инструменте все еще остается слепая зона SSRF.
