# Может ли внедрение HTTP-запроса начаться с перевода строки?

Инжектор учётных данных может не показывать API-ключ в контексте агента и всё равно отправить этот ключ в неправильном запросе. Ошибка возникает, когда шлюз считает ввод агента безобидным текстом, а затем вставляет его в синтаксис HTTP после того, как слой учётных данных уже сделал свою работу.

Я видел, как команды всерьёз вкладываются в хранилища секретов, запросы на подтверждение и журналы аудита, но оставляют между агентом и сетью форматировщик строк. Этот форматировщик становится частью границы безопасности. Если он допускает возврат каретки или перевод строки в назначении, имени пользовательского заголовка или значении заголовка от агента, данные могут сформировать структуру запроса.

Решение намеренно скучное: отклоняйте возврат каретки и перевод строки до формирования запроса, разбирайте структурированные данные вместо сборки строками и тестируйте байты, которые процесс отправляет наружу. Не пытайтесь очищать такие данные обрезкой или заменой. Отклонённый запрос честен. Исправленный запрос может оказаться другим запросом.

## Может ли внедрение HTTP-запроса начаться с перевода строки?

Да. В HTTP/1.1 перевод строки разделяет части сообщения. Если код помещает недоверенный текст в строку запроса или строку заголовка, не контролируя эту границу, злоумышленник может с помощью CRLF, последовательности возврата каретки и перевода строки, завершить текущую строку и начать другую.

Упрощённый уязвимый код формирования запроса часто выглядит безобидно:

```text
GET {path} HTTP/1.1\r\n
Host: {host}\r\n
Authorization: Bearer {vault_token}\r\n
{custom_name}: {agent_value}\r\n
\r\n
```

Предположим, агент передаёт такое значение:

```text
blue\r\nX-Forwarded-Host: internal.example
```

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

Та же ошибка возникает в строке назначения, когда шлюз сам формирует первую строку, или когда один слой разбирает URL, а другой позже объединяет путь, хост или цель прокси через форматирование текста. Точный результат зависит от HTTP-библиотеки и поведения промежуточных компонентов. Эта неопределённость не защищает. Когда разные компоненты по-разному определяют границы сообщения, небольшой пропуск в проверке превращается в инцидент безопасности.

RFC 9110 определяет поля HTTP как синтаксис из имени поля, двоеточия и значения поля. В нём переводы строк также относятся к кадрированию сообщения, а не к обычному содержимому внутри поля. Это важнее общего совета «экранируйте пользовательский ввод». Для этой цели не существует полезной экранированной формы перевода строки внутри поля заголовка. Отклоняйте его.

## Хранилище защищает учётные данные, но не форму запроса

Добавление учётных данных и внедрение запроса решают разные задачи. Первое не даёт агенту увидеть секрет. Второе гарантирует, что запрос, получающий секрет, соответствует форме, одобренной человеком.

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

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

Поэтому список разрешённых имён хостов полезен, но недостаточен. Он ограничивает адрес, на который может указывать разобранный URL. Но он не доказывает, что каждый компонент получил один и тот же разобранный URL, и не останавливает переводы строк в данных пользователя, переопределении заголовка или строке, добавленной после проверки списка. Граница должна отклонять управляющие символы и сохранять структурированные значения до самого HTTP-клиента.

## Отклоняйте CR и LF до создания объекта запроса

Самое безопасное правило можно сформулировать одним предложением: в полях назначения под контролем агента, именах пользовательских заголовков и их значениях после декодирования не должно быть ни `\r`, ни `\n`.

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

Компактная функция проверки должна отказываться исправлять ввод:

```text
validateRequestText(field, value):
  if value contains "\r" or "\n":
    fail(field + " contains a line break")
  return value
```

В реальном коде проверяйте фактическое строковое значение языка, а не его напечатанное представление. JSON-полезная нагрузка может содержать `"\n"`; после разбора JSON это станет одним символом перевода строки. Проверка двух видимых символов обратной косой черты и n не заметит опасное значение.

Не вызывайте `trim()` и не продолжайте работу. В большинстве реализаций обрезка удаляет только символы по краям, поэтому перевод строки в середине сохранится. Замена переводов строк пробелами хуже по другой причине: она меняет запрошенное действие, а у оператора не остаётся ясной записи о том, что агент запросил запрещённый синтаксис. Считайте ввод недопустимым и останавливайтесь до сетевого ввода-вывода.

В ответе об отклонении указывайте поле, но не отражайте чувствительное содержимое. Сообщение `custom header value contains a line break` даёт вызывающей стороне достаточно информации. Если вернуть полное значение, учётные данные или личные данные могут попасть в запись терминала.

## Для имён заголовков нужен более узкий контракт, чем для значений

Значение заголовка может законно содержать пробелы и многие печатаемые символы. Имя заголовка не должно. Возможность выбирать произвольные имена создаёт больше неоднозначности, чем требуется большинству шлюзов действий.

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

Этот ввод нужно отклонить, хотя видимое значение выглядит обычным:

```json
{
  "name": "X-Trace\r\nAuthorization",
  "value": "debug"
}
```

И этот тоже:

```json
{
  "name": "X-Trace: injected",
  "value": "debug"
}
```

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

Для значений заголовков нужна отдельная политика помимо CRLF. Решите, принимает ли шлюз повторяющиеся заголовки, допускает ли запятые и какие заголовки агент может переопределять. Не объединяйте дублирующиеся имена, склеивая текст, если семантика заголовка явно этого не допускает. `Set-Cookie`, `Content-Length`, `Host`, `Authorization` и заголовки перенаправления требуют особой обработки или жёсткого запрета. Я предпочитаю шлюзы, которые полностью резервируют протокольные заголовки и заголовки учётных данных, а агентам предлагают небольшой набор заголовков приложения.

Эта рекомендация иногда встречает возражения: произвольные пользовательские заголовки делают универсальный HTTP-инструмент гибким. Гибкость популярна, потому что не нужно добавлять параметр инструмента, когда API нужен ещё один заголовок. Но для трафика агента с учётными данными это неверное значение по умолчанию. Именованный параметр, например `idempotency_key` или `request_id`, имеет понятный формат и ответственного. У неограниченного набора заголовков нет ни того, ни другого.

## Разберите назначения один раз и сохраняйте их части структурированными

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

Точный список разрешений зависит от действия. Инструмент для вызова API одного поставщика может требовать HTTPS, одного имени хоста и порта 443. Более широкий инструмент может разрешать несколько заранее настроенных источников. В обоих случаях до создания запроса отклоняйте встроенные учётные данные, фрагменты, некорректные имена хостов и любой декодированный возврат каретки или перевод строки. Фрагменты не уходят в сеть, но их принятие создаёт ненужную путаницу в логах и на экранах подтверждения.

Полезная граница запроса выглядит так:

```text
input destination
  -> decode once
  -> reject CR and LF
  -> parse URL
  -> require HTTPS
  -> require allowed host and allowed port
  -> reject user info and fragment
  -> pass parsed URL to HTTP client
```

Порядок намеренный. Список разрешённых хостов, применённый к исходной строке, можно обмануть данными пользователя, например `https://allowed.example@other.example/`. Проверка перевода строки, выполненная только после того, как библиотека нормализовала некорректное значение, может не заметить то, что принял более ранний парсер. Сначала разбирайте значение, чтобы понять его смысл, но отклоняйте запрещённые управляющие символы до того, как любой компонент сможет сформировать из них синтаксис.

Не собирайте HTTP-запрос конкатенацией `scheme + "://" + host + path`. Такой шаблон снова превращает структурированное значение в текст в тот момент, когда вам особенно нужны гарантии парсера. Если библиотека требует отдельные аргументы хоста и пути, проверяйте каждый аргумент по тому же правилу для управляющих символов и используйте структурированный API библиотеки.

## Кодирование создаёт обходы, когда слои расходятся

Буквальный CRLF - тест, который помнят все. Закодированный CRLF - тест, находящий настоящую ошибку.

Агент может передать `%0d%0a` в компоненте URL. Если шлюз проверяет исходную строку, а затем декодирует URL перед использованием собственного средства формирования запроса, проверка защищает не то представление. Перевод строки появляется после проверки. Двойное кодирование, например `%250d%250a`, важно, если разные слои декодируют отдельно.

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

Таблица тестов должна как минимум охватывать такие данные в каждом поле под контролем агента:

- буквальный `\r`, буквальный `\n` и расположенную рядом пару CRLF
- `%0d`, `%0a` и `%0d%0a`, если допускается процентное кодирование
- `%250d%250a`, если другой компонент может декодировать позже
- имя заголовка с двоеточием или пробелом
- назначение с данными пользователя, неожиданным портом или неразрешённым хостом

Не проверяйте только текст ошибки. Убедитесь, что транспорт клиента никогда не запускается для отклонённого ввода. Валидатор, который сообщает об ошибке после создания или постановки запроса в очередь, уже нарушил важное свойство безопасности.

Есть похожая ловушка в форматах конфигурации. У переменных окружения, YAML, JSON и аргументов оболочки свои правила экранирования. Тестовая фикстура, в которой визуально есть `\n`, может содержать два безобидных символа, тогда как декодирование рабочего JSON превратит их в перевод строки. Создавайте тесты через тот же путь разбора, который принимает реальные вызовы агента.

## Ошибка обычно прячется в удобном коде

Опасный код часто появляется после первоначальной проверки безопасности. Кто-то добавляет пользовательские заголовки для нового API, делает отладочный прокси настраиваемым или пишет функцию повторной отправки, которая заново собирает запрос для логирования. Каждое изменение само по себе разумно. Вместе они могут вернуть строковое формирование после того, как исходный шлюз использовал безопасные API клиента.

Я находил такое, прослеживая одно действие от ввода до сокета, а не только ища слово `header`. Ищите интерполяцию строк вокруг `Host:`, `Authorization:`, `Cookie:`, `Content-Length:`, целей запросов, команд прокси, помощников для тестирования сырого HTTP и утилит повтора журналов. Ищите также функции декодирования. Безопасный валидатор у точки входа мало поможет, если поздний путь принимает другое представление.

Типичный сбой состоит из четырёх частей. Шлюз проверяет хост назначения по списку разрешений. Остальную часть назначения он хранит как текст для низкоуровневого клиента. Помощник повтора процентно декодирует этот текст, чтобы логи было легче читать. Затем помощник записывает сырую строку запроса для повтора, и `%0d%0aX-Test:%20yes` превращается в синтаксис сообщения.

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

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

## Логи должны показывать отклонённое намерение, не повторяя секреты

Когда проверка отклоняет запрос, запишите достаточно контекста для расследования: время, идентификатор сессии или процесса агента, имя действия, категорию поля, причину отклонения и безопасный дайджест или длину переданного значения. По умолчанию не храните полное значение заголовка. Заголовки часто содержат токены, личные данные или подписанные данные, которые после копирования в логи становятся новой проблемой управления секретами.

Разделяйте событие аудита попытки действия и событие выполненного действия. Отклонённый запрос не должен выглядеть как неудачный сетевой вызов. Он не дошёл до сети. Это различие помогает при реагировании на инциденты и не позволяет операторам искать удалённый сервис, который никогда не видел запроса.

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

Здесь важно свойство офлайн-проверки. `sp audit verify` может проверить цепочку по шифротексту без ключа хранилища и подтвердить, что записанные события не изменялись. Но он не может определить, не был ли валидатор слишком снисходительным. Схема событий и тесты должны делать видимой категорию отклонённого поля, не сохраняя опасные данные.

## Тестируйте сериализованный запрос, а не только валидатор

Модульные тесты для `contains('\r') || contains('\n')` необходимы, но недостаточны. Они доказывают, что одна функция отклоняет два символа. Они не доказывают, что фактический транспорт не может получить перевод строки, добавленный декодированием, значениями по умолчанию, повторными отправками или пользовательским адаптером.

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

Компактная тестовая матрица ловит больше регрессий, чем длинная подборка расплывчатых тестов безопасности:

```text
field                 input                         expected result
custom header value   "ok\r\nX-Added: yes"          reject before transport
custom header name    "X-Mode: injected"            reject before transport
destination path      "/v1/a%0d%0aX-Added:%20yes"   reject after decoding
allowed destination   "https://api.example/v1/a"    one request, expected host
```

Добавьте тесты свойств, если язык позволяет сделать это удобно. Генерируйте управляющие символы рядом с каждым разрешённым классом символов и проверяйте отклонение. Фаззинг полезен лишь после чёткого определения контракта. Фаззер найдёт странные случаи парсера, но не скажет, были ли произвольные имена заголовков решением продукта или случайностью.

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

## Подтверждение человеком не оправдывает некорректные действия

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

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

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

Первое практическое изменение не требует масштабной переработки. Найдите все пути, которые принимают текст назначения или пользовательские заголовки под контролем агента, добавьте безусловное отклонение CRLF до формирования запроса и напишите тест транспорта, доказывающий, что отклонённый ввод ничего не отправляет. Затем уберите удобный код, который собирает HTTP строками. Именно там эта ошибка снова появляется.
