# Путаница типов содержимого в аутентифицированных вызовах API агентов

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

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

## Аутентифицированный запрос всё равно должен иметь одно значение

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

Рассмотрим конечную точку, которая меняет цель развёртывания:

```http
POST /v1/deployments/promote HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json

{"environment":"staging","release":"2026.07.22"}
```

Код авторизации может разрешать продвижение в `staging`, но запрещать его для production. Этот код корректен только в том случае, если получает то же значение `environment`, которое использует обработчик действия. Если один слой читает JSON, обработчик позже обращается к параметрам формы, а оба источника могут заполнить один объект запроса, вы создали два источника истины.

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

RFC 9110 говорит, что `Content-Type` указывает тип содержимого связанного представления и определяет формат данных и способ их обработки получателем. Значит, этот заголовок относится к смыслу запроса, а не служит украшением. Тот же RFC позволяет получателю без `Content-Type` считать содержимое `octet-stream` или изучить сами данные. Это удобно для универсальной работы с файлами, но опасно как значение по умолчанию для защищённых API действий.

Для конечной точки действия установите такой инвариант:

> Ровно один разрешённый тип содержимого преобразует байты запроса ровно в один проверенный объект команды. Все решения безопасности и побочные эффекты используют этот объект.

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

## Заголовок Content-Type не является схемой

`Content-Type: application/json` не означает: «это форма запроса, которую я ожидал». Он означает, что отправитель заявляет: тело использует тип содержимого JSON. Вам всё ещё нужно решить, поддерживается ли этот тип данным маршрутом, разрешены ли параметры, корректно ли тело с точки зрения синтаксиса и соответствует ли разобранное значение контракту операции.

Защищённая конечная точка должна намеренно принимать небольшой набор представлений. Многие конечные точки команд должны принимать только JSON. Конечная точка загрузки может принимать только multipart. Действие без аргументов должно принимать только отсутствие тела. Чем шире набор разрешённых вариантов, тем больше путей парсинга вы обязаны поддерживать.

OWASP REST Security Cheat Sheet даёт простой практический совет: документировать поддерживаемые типы содержимого и отклонять неожиданные или отсутствующие типы, разрешая отсутствие Content-Type для запроса с нулевой длиной. В рекомендациях также говорится, что тело и заявленный тип должны соответствовать друг другу, иначе производитель и потребитель могут понять запрос по-разному.

Для аутентифицированных действий эту рекомендацию нужно уточнить. Не «сопоставляйте» заявленный тип, изучая первый символ и выбирая парсер. Тело, начинающееся с `{`, не даёт права обрабатывать запрос, объявленный как form, через JSON-парсер. Анализ содержимого превращает ясный контракт в догадку о реализации.

Полезный контракт маршрутов может выглядеть так:

| Маршрут | Разрешённый тип содержимого запроса | Правило для тела |
|---|---|---|
| `POST /v1/deployments/promote` | `application/json` | Обязательный JSON-объект, соответствующий `PromoteRequest` |
| `POST /v1/artifacts` | `multipart/form-data` | Обязательные части, соответствующие `ArtifactUpload` |
| `POST /v1/sessions/revoke` | отсутствует | Должно содержать ноль байт |

Точно определяйте параметры типа содержимого. Если ваш JSON-парсер принимает `application/json; charset=utf-8`, задокументируйте это и нормализуйте параметры одной библиотекой. Если принимается только `application/json`, отклоняйте параметр, не позволяя прокси и приложению расходиться во мнениях. Конкретный выбор менее важен, чем его единообразное применение.

Также разделяйте предпочтение формата ответа в `Accept` и `Content-Type` запроса. Клиент может запросить JSON-ответ и одновременно отправить недопустимое тело. Никогда не позволяйте заголовку `Accept` расширять набор форматов запроса, которые будет разбирать конечная точка действия.

## Для JSON нужны правила, выходящие за пределы синтаксической корректности

JSON-парсер может успешно разобрать ввод, который API всё равно обязан отклонить. Очевидный пример, повторяющиеся имена членов объекта:

```json
{"environment":"staging","environment":"production","release":"2026.07.22"}
```

RFC 8259 говорит, что имена объектов должны быть уникальными, и объясняет почему: получатели не одинаково обрабатывают повторы. Многие сохраняют последнее значение, некоторые завершаются ошибкой, а другие показывают каждую пару. Это задокументированная проблема совместимости, а не теоретическое требование к стилю.

Представьте, что middleware авторизации использует парсер, сохраняющий первое значение `environment`, а последующий декодер оставляет последнее. Middleware разрешает staging, а обработчик продвигает production. Это нельзя исправить более удачными именами ролей или ещё одним требованием в токене. Запрос нужно отклонить до того, как любой компонент примет решение.

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

- Отклоняйте неизвестные члены объекта для запросов действий, если у вас нет явно указанной причины сохранять их ради совместимости.
- Требуйте ожидаемый тип JSON. Boolean не превращается в строку только потому, что в ней написано `true`, а целочисленный идентификатор не является числом с плавающей точкой.
- Устанавливайте ограничение размера тела до начала парсинга. Валидатор схемы не защитит память, которую вы уже исчерпали чтением огромного тела.
- Решите, может ли поле отсутствовать, иметь значение `null` или быть пустой строкой. Это три разных состояния.
- Отклоняйте данные после основного значения и расширения парсера, такие как комментарии, `NaN` или имена без кавычек, если библиотека их поддерживает.

Не проводите авторизацию непосредственно по универсальной map-структуре. Декодируйте запрос в тип с явной схемой, выполните семантическую проверку, а затем создайте внутренний тип команды, который не хранит необработанные артефакты парсера. Обработчику, который получает `PromoteCommand { environment, release }`, сложнее переосмыслить ввод, чем обработчику, который получает map, коллекцию query-параметров, объект запроса и необработанное тело.

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

## Тела форм создают скрытые правила для массивов и вложенности

`application/x-www-form-urlencoded` кажется простым, потому что напоминает строку запроса. Но всё меняется, когда библиотеки начинают придавать смысл повторяющимся именам, скобочной записи, плюсам и пустым значениям.

Рассмотрим такие тела:

```text
role=user&role=admin
role[]=user&role[]=admin
role[user]=1&role[admin]=1
role=user%26role%3Dadmin
```

Разные фреймворки могут трактовать их как последнее или первое скалярное значение, массив, объект, буквальные имена полей или ошибку парсинга. Некоторые middleware разбирают формы для любого метода запроса. Некоторые фреймворки объединяют query-параметры и параметры формы в удобный объект. Именно в таком объекте защищённые API теряют понимание того, что на самом деле отправил вызывающий.

В рекомендациях OWASP по тестированию HTTP parameter pollution отмечается, что поведение зависит от взаимодействия приложения, веб-сервера, WAF и middleware. Поэтому нужно тестировать необработанные повторяющиеся параметры, а не полагаться на документацию парсера одного фреймворка.

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

Если форму всё же нужно поддерживать, задайте для такой конечной точки отдельную политику парсинга:

1. Отклоняйте повторяющиеся имена, если схема не определяет это поле как список.
2. Отклоняйте скобочную запись, если схема не определяет её точную форму и ваш парсер не обрабатывает её единообразно.
3. Держите query-параметры отдельно от полей формы. Не позволяйте одному источнику перезаписывать другой.
4. Преобразуйте разобранные поля в тот же типизированный внутренний объект команды, который используется маршрутом JSON, только после проверки.
5. Тестируйте процентное кодирование, `+` и `%20`, пустые значения, отсутствие `=` и дублирующиеся поля через рабочий путь обработки запросов.

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

## Multipart предназначен для загрузок, а не для гибкого JSON

`multipart/form-data` решает конкретную задачу: передаёт несколько независимо заголовленных частей, часто с файлами. RFC 7578 определяет его для значений форм и требует параметр boundary, отделяющий части. Каждая часть также может содержать собственные заголовки и метаданные имени файла.

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

Распространённая неудачная конструкция принимает часть JSON `metadata` вместе с файлом, а затем также принимает поля верхнего уровня, которые могут переопределить metadata:

```text
Content-Disposition: form-data; name="metadata"

{"project":"alpha","visibility":"private"}

Content-Disposition: form-data; name="visibility"

public
```

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

Стройте multipart-конечные точки вокруг именованных частей с разными задачами. Например, принимайте ровно одну часть `file` и ровно одну часть `manifest`. Требуйте, чтобы `manifest` был JSON со своей строгой схемой. Отклоняйте имена частей, не перечисленные в контракте загрузки, повторяющиеся одиночные части, задавайте отдельные лимиты для всего тела и файла и решите, обязательны ли значения `Content-Type` для частей.

Не доверяйте имени файла как пути, MIME-заявлению как классификации файла или поведению multipart-парсера с временными файлами как средству защиты. Это отдельные проблемы загрузки. Правило против путаницы парсеров проще: входные данные для авторизации должны поступать из одного именованного проверенного источника. Если `manifest.project` определяет место сохранения файла, никакая другая часть, query-параметр или заголовок не должны менять этот проект.

Если команда не содержит файла, не принимайте multipart. Каждый дополнительный разрешённый тип содержимого даёт ещё один способ заставить компоненты разойтись в интерпретации.

## Пустое тело является контрактом, а не отсутствием проверки

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

Конечная точка с контрактом «без тела» должна отклонять всё перечисленное ниже:

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/json
Content-Length: 2

{}
```

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11

scope=other
```

```http
POST /v1/sessions/revoke HTTP/1.1
Transfer-Encoding: chunked

0

```

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

Для маршрута без тела установите такие правила до бизнес-логики:

- Запрос не содержит байтов содержимого.
- Маршрут не принимает `Content-Type`, кроме явно разрешённого правила совместимости.
- Маршрут не объединяет query-параметры с командой, если каждое разрешённое имя query-параметра не имеет собственной схемы.
- Сервер записывает действие как не имеющее аргументов, а не сохраняет универсальный объект запроса, который позже можно принять за входные данные.

RFC 9110 описывает содержимое запроса с учётом семантики метода и не придаёт телу универсального смысла только потому, что запрос использует POST. Этот смысл задаёт контракт ресурса.

Неловкий случай возникает, когда библиотека клиента всегда отправляет `{}`. Не расширяйте конечную точку только ради неё. Исправьте клиент или создайте отдельный задокументированный маршрут. Тело, которое сейчас ни на что не влияет, после изменения обработчика легко становится случайным каналом ввода.

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

Самый безопасный конвейер обработки запроса движется в одном направлении. Поступают необработанные байты. Маршрут выбирает один разрешённый парсер. Парсер создаёт типизированное значение. Проверка формирует каноническую команду. Авторизация оценивает эту команду. Исполнитель получает ту же команду.

```text
raw HTTP request
  -> route and media-type check
  -> bounded body read
  -> one strict parser
  -> schema and semantic validation
  -> canonical command
  -> authorization
  -> execution and audit record
```

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

Каноническая команда является практической границей, а не элементом диаграммы. Она должна содержать только значения, необходимые исполнителю, и исключать необработанный текст тела, коллекции форм, объекты запроса фреймворка и псевдонимы. Если исполнитель получает `target_environment`, он не должен позже обращаться к `req.query.environment`, потому что цель отсутствовала или была неудобна.

Это улучшает и аудит. Записывайте аутентифицированного субъекта, конечную точку, принятый тип содержимого, дайджест запроса, безопасные для хранения поля канонической команды, решение авторизации и результат. Без необходимости не сохраняйте необработанные тела: в них могут быть учётные данные, загруженные файлы и пользовательские данные. Дайджест позволяет связать событие с сохранённым доказательством, не превращая логи в хранилище секретов.

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

## Тестируйте разногласия, а не только успешный парсинг

Модульные тесты, которые десериализуют один корректный JSON-файл, почти ничего не говорят о согласованности парсеров. Цель тестирования, публичный путь запроса: балансировщик или обратный прокси, шлюз, middleware фреймворка, обработчик маршрута и любой сервис, который повторно разбирает тело.

Создайте небольшой набор негативных тестов для каждой аутентифицированной операции. Запускайте его в CI в отдельном окружении и проверяйте как ответ, так и отсутствие побочных эффектов. Ответ `400` ничего не доказывает, если до него уже были отправлены сообщения в очередь, создано событие аудита или частично записан файл.

Начните с этого shell-теста. Он намеренно отправляет необработанные тела, вместо того чтобы использовать сгенерированный клиент, отбрасывающий некорректные данные:

```bash
base=https://api.test.example
bearer='test-token'

send() {
  name=$1
  type=$2
  body=$3
  code=$(curl -sS -o "/tmp/${name}.out" -w '%{http_code}' \
    -X POST "$base/v1/deployments/promote" \
    -H "Authorization: Bearer $bearer" \
    -H "Content-Type: $type" \
    --data-binary "$body")
  printf '%-28s %s\n' "$name" "$code"
}

send valid_json 'application/json' \
  '{"environment":"staging","release":"2026.07.22"}'
send duplicate_json 'application/json' \
  '{"environment":"staging","environment":"production","release":"2026.07.22"}'
send form_body 'application/x-www-form-urlencoded' \
  'environment=production&release=2026.07.22'
send false_json 'application/json' \
  'environment=production&release=2026.07.22'
```

Ожидаемый результат должен содержать один успешный ответ и три отклонения на стороне клиента:

```text
valid_json                   200
 duplicate_json               400
form_body                    415
false_json                   400
```

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

Расширьте набор случаями, нацеленными на границы между компонентами:

| Случай | Что должно произойти |
|---|---|
| Отсутствует `Content-Type`, но тело непустое | Отклонить до парсинга |
| JSON-объект содержит неизвестное поле | Отклонить или применить задокументированное правило совместимости |
| Повторяющееся скалярное поле формы | Отклонить |
| Значение query конфликтует со значением JSON | Отклонить или игнорировать query согласно контракту маршрута |
| Multipart содержит две части `manifest` | Отклонить |
| Маршрут без тела получает `{}` | Отклонить |

Затем изучите аудитный след. Для каждого отклонённого ввода должна существовать трассировка с маршрутом и классом отказа, но без чувствительного содержимого запроса. Каждый принятый ввод должен давать одну каноническую команду. Если в логах видно, что шлюз получил одну цель, а обработчик записал другую, вы нашли разногласие, даже если тест завершился ответом 2xx.

## Прокси и middleware тоже являются парсерами

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

В рекомендациях OWASP по HTTP request smuggling описан более масштабный вариант этой проблемы: посредники и серверы могут по-разному интерпретировать границы запроса, особенно при преобразовании протоколов и фрейминге. Путаница типов содержимого не требует request smuggling, чтобы стать опасной, но обе проблемы возникают, когда разным слоям позволяют принимать несовместимые решения о парсинге.

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

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

Тестируйте версии HTTP и варианты развёртывания, которые действительно используются в production. Запрос, корректный на локальном сервере разработки, может повести себя иначе, когда клиент HTTP/2 обращается к прокси, пересылающему запрос приложению по HTTP/1.1. Цель не в том, чтобы создать лабораторию для исследования атак. Нужно, чтобы рабочая цепочка доказывала: для каждого принятого запроса она создаёт один объект команды.

## Шлюзы агентов должны сохранять эту границу

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

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

Давайте агентам инструменты, отражающие контракт, вместо универсального действия «выполнить любой HTTP-запрос» для чувствительных систем. Инструмент продвижения должен принимать типизированные аргументы `environment` и `release`. Его реализация должна сериализовать один JSON-объект, установить один тип содержимого и отклонять входные данные инструмента, не соответствующие схеме API. Принимающий сервис должен повторить проверку. Схемы инструментов уменьшают количество ошибок, но не заменяют недоверие на стороне сервера.

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

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