Может ли userinfo в URL скрыть адрес назначения API?
Userinfo в URL может маскировать адрес назначения API. Узнайте, как безопасно разбирать, отклонять, отображать и тестировать userinfo до отправки запросов агентами.

Агента, который принимает произвольный URL, можно направить туда, куда его оператор вовсе не собирался. Userinfo упрощает эту ошибку: знакомое имя хоста стоит перед символом @, а настоящий адрес назначения находится после него. Если экран одобрения, список разрешенных адресов или журнал аудита воспринимает всю строку как нечто похожее на имя хоста, запрос может выглядеть одобренным, но уйти на другой сервер.
Считайте userinfo недопустимым вводом для исходящих API-действий, если только у вас нет узкой и документированной причины поддерживать его ради совместимости. Один раз разберите URL на границе системы, отклоните непустое поле userinfo до того, как учетные данные попадут в запрос, а все последующие решения принимайте по разобранным полям. Это не экзотический трюк с URL. Обычный синтаксис сталкивается с экраном проверки, на котором человеку приходится слишком быстро читать слишком много знаков препинания.
Имя хоста находится после последнего знака @
В абсолютном HTTP URL часть authority расположена между // и следующим /, ? или #. RFC 3986 описывает authority как необязательный userinfo, затем @, затем хост и необязательный порт. Значит, хостом нельзя считать любой текст, который первым идет после //.
Рассмотрим такой запрос:
https://[email protected]/v1/charges
Человек, который быстро смотрит на начало строки, может заметить api.example.com и на этом остановиться. Совместимый со стандартом парсер разделит URL так:
scheme: https
userinfo: api.example.com
host: collector.invalid
port: 443
path: /v1/charges
Для TCP-соединения и проверки имени TLS используется collector.invalid. Строка перед @ не указывает на удаленный сервер. Это userinfo, устаревшая часть грамматики URL, которая когда-то использовалась для имен и паролей.
Та же проблема встречается в более привычно выглядящей форме:
https://billing.example.com:[email protected]/invoices
Все до @ по-прежнему относится к userinfo, включая двоеточие и текст после него. Удаленный хост остается evil.invalid. Нельзя надежно определить адрес назначения по первому фрагменту исходного URL, похожему на имя хоста.
Не пытайтесь решить проблему, обучая проверяющих замечать этот символ. Такие ошибки случаются при разборе инцидентов, в конце долгого дня и когда агент создает много запросов на действия. Контроль, который требует безупречно разбирать URL глазами, ненадежен.
Стандарт URL, используемый браузерами и многими средами выполнения, дает тот же практический результат для специальных схем, таких как HTTPS: разобранные поля имени пользователя и пароля отделены от имени хоста. Точное поведение парсеров может различаться при некорректном вводе и экранировании. Поэтому разбирайте URL той же средой выполнения, которая отправит запрос. Не проверяйте одной библиотекой, а выполняйте другой, принимающей иной набор строк.
Полезное правило простое: решение о том, куда разрешено отправить запрос, можно принимать только по разобранному имени хоста. Исходный текст URL служит доказательством, но не источником полномочий.
Userinfo создает проблему проверки раньше, чем проблему сети
Сетевой клиент обычно знает, куда направляется. Сбой происходит раньше, когда человек или механизм одобряет неверное представление этого адреса.
В типичном потоке агента исходный текст может попасть в несколько мест: аргумент вызова инструмента, карточку одобрения, журнал сеанса, сообщение об ошибке и уведомление. Если одно из этих представлений сообщает Calling api.example.com, потому что извлекает текст перед @, оно сообщает оператору неправду. Если другое представление записывает полную строку, но обрезает ее до фиксированной длины, настоящее имя хоста может исчезнуть вовсе.
Это важно, даже когда у агента нет учетных данных для целевого сервера. Исходящий запрос может нести пользовательские данные, подписанное тело запроса, bearer-токен, выбранный слишком широким правилом, или просто достичь внутреннего адреса, который никогда не должен получать трафик от агента. История с учетными данными привлекает внимание, потому что она конкретна. Целостность адреса назначения требует такой же дисциплины.
Часто путают безопасность отображения URL и безопасность его передачи. Экранирование @ в HTML-представлении может сделать страницу понятнее, но не определяет, к чему подключится HTTP-клиент. И наоборот, парсер может правильно выполнить сетевой вызов, пока плохо спроектированный экран одобрения все еще подталкивает человека одобрить не тот хост. Нужны и правильное решение о передаче, и честное отображение.
Не заменяйте @ безобидным символом в сохраненном запросе и не продолжайте работу. Так вы скрываете ввод, вызвавший отказ, и усложняете последующее расследование. Сохраните исходную строку как исходный ввод, отметьте запрос как отклоненный и запишите разобранную причину, не добавляя встроенные учетные данные в доступный для чтения журнал.
В представлении сначала должно быть отдельное поле адреса назначения, например Host: collector.invalid, а исходный URL следует разместить ниже. Так задача с пунктуацией превращается в прямое утверждение. У проверяющего также появляется стабильное поле, которое можно сравнить с запрошенными учетными данными или нужной интеграцией.
Отклонять userinfo безопаснее, чем исправлять его
Для шлюза действий агента разумный вариант по умолчанию - отклонять каждый исходящий HTTP URL, у которого непустое разобранное поле имени пользователя или пароля. Большинство API-интеграций и так передают учетные данные в заголовках запроса или через механизм добавления учетных данных. Поддержка userinfo расширяет поверхность атаки и не решает обычную задачу API.
Порядок проверки важен. Сначала разберите исходную строку. Отклоняйте некорректные URL, неподдерживаемые схемы и userinfo до проверки хоста по списку разрешенных адресов, запроса одобрения, перенаправлений, разрешения DNS или выбора учетных данных. Благодаря этому запрос не будет показан как допустимый, если позже вы отбросите его часть.
Это правило выражает такой псевдокод:
u = parse_absolute_url(raw_url)
if u.scheme not in {"https", "http"}:
deny("unsupported scheme")
if u.username != "" or u.password != "":
deny("URL userinfo is not accepted")
host = normalize_hostname(u.hostname)
if host == "":
deny("missing hostname")
if not destination_is_allowed(u.scheme, host, u.port):
deny("destination is not allowed")
send(u)
Парсер должен возвращать структурированные поля. Разделение по @, удаление префикса или поиск подстроки с именем хоста ломаются на обычных вариантах записи. В исходном вводе authority может содержать несколько символов @. Парсер определяет, какой разделитель относится к синтаксису и принадлежат ли предыдущие символы userinfo. Он также последовательнее самодельной строковой логики обрабатывает IPv6-литералы в квадратных скобках, явно указанные порты, percent encoding и пустые компоненты.
Отказ от userinfo дает отправителю понятный способ исправить запрос: используйте https://api.example.com/path, а HTTP-аутентификацию передавайте через предназначенный для этого механизм учетных данных. Отправителю не нужно гадать, превратили ли вы молча https://name@host в https://host.
Стоит признать один случай совместимости. В некоторых старых URL Basic authentication записан как https://name:secret@host/path. Если при миграции нужно обработать такие URL, сделайте для этого одноразовый путь импорта: извлеките учетные данные в защищенное хранилище, подтвердите разобранный хост и удалите исходную строку из записи импорта там, где это разрешает политика. Не позволяйте API действий во время работы принимать такие URL бесконечно. Временный код совместимости часто становится постоянной поверхностью атаки.
Спискам разрешенных хостов нужны разобранные метки, а не дружелюбные строки
Список разрешенных адресов назначения должен сравнивать нормализованные разобранные имена хостов, а не искать подстроку в исходном URL. Правило raw_url.includes("api.example.com") пропустит и https://[email protected], и https://api.example.com.evil.invalid. Ни один из них не отправляет запрос на api.example.com.
Точное совпадение хоста - самое понятное правило. Если интеграции нужен только api.example.com, разрешите это имя и отклоняйте все остальные. Если ей действительно нужны поддомены, сравнивайте DNS-метки: разрешайте example.com и имена, оканчивающиеся на .example.com, но отклоняйте badexample.com и example.com.evil.invalid.
Понятная форма реализации выглядит так:
function allowedHost(host, root) {
const h = host.toLowerCase().replace(/\.$/, "")
const r = root.toLowerCase().replace(/\.$/, "")
return h === r || h.endsWith("." + r)
}
Этот код предполагает, что парсер URL уже вернул имя хоста, а вызывающий код уже отклонил userinfo. Ему не следует передавать полный URL. Разделение обязанностей не позволит другому вызывающему коду позже передать строку authority с портом, именем пользователя или @.
Для интернационализированных доменов тоже нужно принять решение. Браузеры часто сериализуют имена хостов в ASCII с обработкой IDNA, а пользователь может видеть текст Unicode. Для сравнения используйте ту же каноническую форму, что и ваш клиент запросов, а при различии показывайте и канонический хост, и читаемый вариант. Не утверждайте, что две строки указывают на один домен, только потому, что они похожи в пропорциональном шрифте.
Для IP-адресов нужно отдельное правило. Список разрешенных имен хостов сам по себе не делает IP-литерал безопасным, а DNS-разрешение после одобрения может изменить адрес, которого достигнет имя хоста. Если ваша модель угроз включает доступ к локальным сервисам, явно решите, допустимы ли частные, loopback, link-local и локальные IPv6-адреса. Отказ от userinfo необходим, но сам по себе не устраняет SSRF.
Порты тоже важны. https://api.example.com:8443 может быть допустимым адресом партнера или неожиданным сервисом администрирования. Записывайте фактический порт и учитывайте его при одобрении, если интеграция ограничивает порт. Одна метка хоста не описывает весь сетевой адрес назначения.
Перенаправления должны проходить через ту же проверку
Разрешенный начальный URL не делает разрешенными все цели перенаправления. HTTP-перенаправления содержат новые указания адреса, поступающие от удаленного сервера. Шлюз агента должен разобрать и авторизовать каждое из них до перехода.
Предположим, агент запрашивает https://api.example.com/export. Этот хост отвечает 302 со следующим location:
https://[email protected]/download?id=42
Клиент, который автоматически следует перенаправлениям, затем подключится к receiver.invalid. Если шлюз одобрил только первый URL, его список разрешенных адресов и экран одобрения уже не описывают выполненное сетевое действие.
Обрабатывайте перенаправления в цикле с ограничением, выбранным для вашего клиента. Для каждого значения location разрешите относительную ссылку относительно текущего одобренного URL, разберите получившийся абсолютный URL, примените те же правила для схемы, userinfo, хоста, порта и адреса, затем решите, можно ли продолжать. Записывайте и исходный ответ, и разобранный адрес перенаправления.
По умолчанию не передавайте учетные данные между хостами. HTTP-библиотеки по-разному сохраняют заголовок Authorization после перенаправления на другой хост, а пользовательские заголовки могут вести себя иначе. Самый безопасный вариант для шлюза - связать учетные данные с конкретным одобренным адресом и создавать новый исходящий запрос только после того, как цель перенаправления пройдет авторизацию. Перенаправление с одного хоста поставщика на другой может быть ожидаемым, но его следует задать явным правилом, а не оставлять на волю настроек библиотеки.
Нужно учитывать и обработку метода. Ответ 303 обычно меняет следующий запрос на GET, а 307 и 308 сохраняют метод и тело. Если агент отправляет чувствительное тело, перенаправление с сохранением метода может послать его в другое место. Записывайте метод на каждом шаге и показывайте конечный адрес в результате действия.
Клиент также можно настроить так, чтобы он не следовал перенаправлениям. Для узких API-инструментов это разумный выбор. Верните ответ с перенаправлением агенту и потребуйте, чтобы он явно запросил следующий адрес. Появится еще одно событие одобрения, зато оператор получит ясную точку для оценки смены хоста. При широкой поддержке HTTP автоматические перенаправления приемлемы, только если каждый шаг проходит ту же проверку.
Выбирайте учетные данные после проверки адреса назначения
Опасный порядок легко описать: выбрать учетные данные, потому что в исходном URL встречается знакомое имя сервиса, затем разобрать URL и отправить запрос. Трюк с @ может превратить знакомый текст в userinfo, а выбранный секрет уйдет на хост под контролем атакующего.
Безопасный порядок столь же прост. Сначала разберите и проверьте URL. Затем авторизуйте его схему, хост, порт и состояние перенаправления. Лишь после этого найдите учетные данные, связанные с одобренным адресом, и добавьте их в исходящий запрос. Не допускайте, чтобы секрет попал в аргументы инструмента, память агента или возвращаемые значения.
Это устраняет и менее заметную, но распространенную ошибку настройки. Учетные данные, связанные с api.example.com, не должны автоматически отправляться на uploads.example.com, даже если оба имени находятся в одном родительском домене. У разных хостов часто разное владение, завершение TLS, ведение журналов или области разрешений. Начинайте с точной привязки к хосту. Расширяйте совпадение только тогда, когда интеграция объясняет, зачем это нужно.
Добавлять учетные данные в заголовок лучше, чем размещать их в userinfo URL, потому что так отделяется адрес назначения от аутентификации. В записи запроса можно указать, что заголовок авторизации был добавлен, не сохраняя его значение. Агент получает ответ, нужный для продолжения работы, а не секрет, который можно использовать повторно.
Sallyport следует этому разделению: API- и SSH-учетные данные хранятся в зашифрованном хранилище, а исходящее действие выполняется без раскрытия этих данных агенту. Для любого шлюза с такой моделью отказ от userinfo до поиска учетных данных закрывает разрыв между адресом, который имел в виду оператор, и хостом, получающим запрос.
Не доверяйте и заголовку запроса, который агент прислал как указание адреса назначения. Заголовок Host, authority в HTTP/2, хост из URL, настройка прокси и имя TLS-сервера могут взаимодействовать по-разному в зависимости от клиента. Шлюз должен сам управлять настройками соединения и выводить их из проверенного разобранного URL. Если вы разрешаете пользовательские заголовки, считайте их содержимым запроса, а не разрешением изменить маршрутизацию.
В карточках одобрения сначала показывайте разобранный адрес назначения
Карточка одобрения должна отвечать на три конкретных вопроса, не заставляя читателя восстанавливать URL: какой процесс сделал запрос, какая операция будет выполнена и какой хост ее получит. Поместите разобранное имя хоста и порт в отдельную строку адреса назначения. Рядом укажите HTTP-метод и путь. Исходный URL показывайте как подтверждающие сведения, а не как единственный сигнал адреса назначения.
Для использованного выше подозрительного запроса полезная карточка выглядела бы так:
Process: signed agent process
Action: POST /v1/charges
Host: collector.invalid:443
Result: blocked because URL userinfo is present
Input: https://[email protected]/v1/charges
Она не должна показывать api.example.com как метку, выведенную из левой части authority. Не стоит ограничиваться и надписью External HTTP request, потому что она не дает человеку содержательных оснований для решения.
Одобрение на сеанс и одобрение каждого вызова решают разные задачи для человека. Одобрение сеанса означает, что конкретный запущенный процесс может пользоваться шлюзом в течение своей работы. Одобрение вызова означает, что особенно чувствительные учетные данные или действие требуют нового решения человека. Ни то ни другое не заменяет базовую проверку URL. Даже доверенный процесс можно обмануть недоверенным комментарием в задаче, полем метаданных пакета или сгенерированным файлом конфигурации.
Лестница решений Sallyport сначала использует заблокированное хранилище как абсолютную остановку, затем авторизацию сеанса и необязательное подтверждение для каждого секрета. Такая структура лучше всего работает, когда некорректные адреса отсеиваются до карточки одобрения: оператору не нужно решать, изменила ли пунктуация URL конечную точку.
Пишите конкретные сообщения об отказе. Userinfo is not allowed in outbound URLs подсказывает разработчику агента, что исправить. Invalid request ведет к повторным попыткам, самодельному экранированию и давлению с целью ослабить проверку. Не повторяйте пароль, если парсер его извлек. В сообщении можно назвать запрещенный компонент, не воспроизводя его содержимое.
Тестируйте обманчивые входные данные, а не только корректные URL
Набор тестов проверки должен содержать примеры, рассчитанные на то, чтобы обмануть читателя и примитивные строковые проверки. Успешная обработка https://api.example.com/v1 почти ничего не доказывает о границе, где агент может отправить произвольный текст.
Начните с таких случаев и проверяйте разобранный хост, решение и причину:
ALLOW https://api.example.com/v1 host=api.example.com
DENY https://[email protected]/v1 reason=userinfo
DENY https://name:[email protected]/v1 reason=userinfo
DENY https://api.example.com.evil.invalid/v1 reason=host
DENY https://api.example.com:444/v1 reason=port
DENY https://[::1]/v1 reason=address
Добавьте фикстуру перенаправления. Пусть разрешенный тестовый сервер вернет перенаправление, в location которого есть userinfo, и убедитесь, что клиент записывает заблокированный второй шаг, не отправляя запрос серверу назначения. Так обнаруживается частая ошибка: проверка начального URL находится в одном пути кода, а обработка перенаправления скрыта в callback библиотеки.
Намеренно тестируйте percent encoding, но не считайте каждый закодированный @ эквивалентным. Во многих парсерах %40 в пути остается данными пути, а настоящий @ в authority работает как разделитель. Передавайте точную исходную строку в парсер, который используется в продакшене, и проверяйте его поля. Важен не самодельный принцип декодирования. Важно, чтобы непустое разобранное поле userinfo не могло попасть к отправителю запроса.
Проверяйте также отображение в журнале и карточке одобрения. Механизм безопасности может правильно отказать, но оставить плохую операционную запись, если интерфейс обрезает настоящее имя хоста или показывает разобранный пароль. Снимочные тесты строк с адресом назначения полезны, потому что визуальные ошибки часто появляются после безобидных на вид изменений дизайна.
Наконец, проверьте цепочку аудита и путь отзыва вокруг отклоненного вызова. Отказ должен быть достаточно заметным для расследования, но никогда не должен включать добавленные секреты. Полезная запись содержит исходный запрос с надлежащим контролем доступа, разобранную схему и хост, причину решения, идентификатор вызывающего процесса и факт, что исходящее действие не произошло.
Синтаксис URL не заменяет правила безопасности
Некоторые команды отвечают на граничные случаи URL растущим набором исключений: разрешают имя пользователя для одного поставщика, особый порт для одной среды, доверяют перенаправлению, только если заголовок выглядит правильно, и исправляют строковое сопоставление после каждого инцидента. Этот подход кажется гибким, потому что не нужно отказывать необычному запросу. Но он создает правила, которые никто не может надежно проверить.
Сохраняйте правило небольшим. Исходящие запросы имеют разобранный адрес назначения. Userinfo отклоняется. Разрешенные схема, хост, порт и класс адреса заданы явно. Перенаправления повторно проходят те же проверки. Учетные данные выбираются только после прохождения адресом проверки. Каждое решение создает запись, которая прямо называет разобранный хост.
Такое правило отклонит несколько старых URL, которые мог бы принять браузер. И это хорошо. Автономному агенту не нужны все исторические возможности адресной строки браузера. Ему нужен узкий интерфейс, в котором сложно перепутать действие, адрес назначения и учетные данные.
Если этот интерфейс нужно изменить, сделайте исключение видимой именованной возможностью с тестами и решением о сроке действия. Не прячьте его в коде очистки URL. Первый враждебный ввод найдет разницу между текстом, похожим на хост, и хостом, к которому действительно подключается ваш клиент.
Вопросы и ответы
Может ли знак @ в URL изменить хост назначения?
Да. В URL вида https://[email protected]/path сервером назначения будет evil.example, а не api.example.com. Текст перед @ относится к userinfo, поэтому URL от агента с таким фрагментом легко вводит человека в заблуждение при проверке.
Допустим ли userinfo в синтаксисе URL?
Это допустимый синтаксис URL, хотя большинству API-клиентов незачем его принимать. RFC 3986 разрешает необязательный userinfo в части authority, но допустимость в грамматике не означает, что его стоит пропускать через границу действий агента.
Должен ли API-шлюз отклонять URL с userinfo?
Отклоняйте такой URL на входе, если нет строго определенной потребности в совместимости. Не удаляйте userinfo молча и не продолжайте работу: так вы измените запрос отправителя и оставите вводящую в заблуждение запись.
Отправляет ли user:[email protected] трафик на user?
Нет. У https://user:[email protected]/v1 имя хоста - api.example.com, а user:pass относится к userinfo. Парсер возвращает эти поля отдельно, и проверки безопасности должны опираться на разобранное имя хоста, а не на исходную строку.
Стоит ли помещать учетные данные Basic authentication в URL?
Учетные данные для Basic auth должны находиться в HTTP-заголовке Authorization, а не в URL. Данные из URL гораздо проще случайно записать в журналы, историю, скопированные команды и сообщения об ошибках.
Усложняют ли перенаправления проверку URL userinfo?
Каждая цель перенаправления должна проходить те же проверки разбора и авторизации, что и исходный URL. Если проверять только первый URL, разрешенный публичный адрес может перенаправить агента на неразрешенный хост.
Совпадает ли api.example.com.evil.example с api.example.com?
Нет. api.example.com.evil.example - поддомен evil.example, а [email protected] на самом деле указывает на api.example.com. Сначала разберите хост, затем сравните метки домена по явному правилу списка разрешенных адресов.
Как проверить URL для исходящего API-запроса?
Не используйте регулярные выражения для всего URL. Возьмите совместимый со стандартом парсер, явно отклоняйте userinfo, при необходимости требуйте HTTPS, нормализуйте разобранное имя хоста и сверяйте его с разрешенными хостами или суффиксами доменов.
Что должен записывать журнал аудита об HTTP-запросе агента?
Хорошая запись аудита хранит исходный ввод для расследования и отдельно сохраняет разобранные поля: схему, имя хоста, порт, путь, цель перенаправления и решение. Показывайте разобранное имя хоста заметно, чтобы проверяющему не приходилось мысленно разбирать пунктуацию.
Как проверить инструмент агента на скрытые адреса назначения в URL?
Обычно достаточно небольшого правила проверки, но его нужно применять до одобрений и добавления учетных данных. Добавьте тесты для @, percent-encoded разделителей, нескольких символов @, перенаправлений, имен хостов в разном регистре и завершающих точек, чтобы последующая переработка кода не вернула уязвимость.