# Ошибки кодирования URL: тестируйте входные данные API агента перед вызовами

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

Я видел, как команды одобряли запрос, выглядевший в журнале инструмента как `GET /records/alice`, а потом часами выясняли, что сервер получил маршрут с лишней косой чертой, повторяющимся query-параметром или декодированной последовательностью обхода. Агенту не требовался экзотический эксплойт. Достаточно обычного текста вроде `a+b`, `%2F`, `&admin=true` или имени на нелатинском языке.

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

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

Запрос может менять смысл в каждой точке, где программа разбирает или заново собирает URL. Последовательность символов, созданная агентом, это только начало. Клиентская библиотека может нормализовать ее, reverse proxy переписать, а фреймворк приложения декодировать до сопоставления маршрута или привязки параметров.

RFC 3986 делит URI на компоненты: схему, authority, путь, query и fragment. В пути `/` является разделителем. В query `&` и `=` обычно получают специальное значение, хотя RFC 3986 не задает универсальную грамматику query-строк. Это различие объясняет большинство ошибок, которые бездумно называют «проблемами кодирования».

Представим действие, которое должно получить один проект:

```text
GET https://api.example.test/projects/{project_id}
```

Если `project_id` равен `north/ops`, эти два целевых запроса различаются:

```text
/projects/north/ops
/projects/north%2Fops
```

В первом после `projects` находятся два сегмента пути. Во втором косая черта должна остаться частью одного сегмента. Сохранится ли это значение, зависит от всего маршрута от клиента до приложения. Одни стеки декодируют `%2F` до сопоставления маршрута и превращают его в первый вариант. Другие отклоняют запрос. Тест клиента, который лишь проверяет, что «URL закодирован», почти ничего не доказывает.

Та же ловушка встречается в query. Действие, которое ищет точную фразу, не должно строить такой URL:

```text
/search?q=USER_TEXT
```

подставляя вместо `USER_TEXT` необработанный текст. При значении `red&limit=500` получится:

```text
/search?q=red&limit=500
```

Теперь приложение видит два query-параметра. Если код создает query-объект и передает `red&limit=500` как значение `q`, результат должен быть таким:

```text
/search?q=red%26limit%3D500
```

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

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

## Параметр пути - это один сегмент, а не незаконченный URL

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

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

```javascript
const target = base + "/projects/" + projectId + "/builds";
```

Этот код не задает для `projectId` отдельного смысла компонента. Если значение содержит `/`, `?`, `#` или `%`, результат зависит от последующих операций с `target`. Кроме того, возникает другая ошибка: кто-то видит в журнале уже закодированное значение, вызывает `encodeURIComponent` еще раз «для надежности» и получает другой идентификатор.

Собирайте сегменты как данные, кодируйте каждый сегмент один раз и соединяйте только те разделители, которыми владеет маршрут. В JavaScript контракт можно явно показать небольшим помощником:

```javascript
function pathSegment(value) {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error("project id must be a nonempty string");
  }
  return encodeURIComponent(value);
}

const path = "/projects/" + pathSegment(projectId) + "/builds";
```

`encodeURIComponent` подходит для этого случая: он кодирует `/`, `?`, `#`, `&` и `=`, которые иначе меняли бы путь либо начинали query или fragment. При этом небольшой набор символов RFC 3986, включая апострофы и круглые скобки, остается незакодированным. Обычно это не меняет структуру пути, но строгий контракт API может требовать более жесткого кодирования. Решайте это по спецификации API, а не по привычке.

Не используйте `encodeURI` для отдельного сегмента. Он сохраняет разделители URI, поскольку рассчитан на полный URI. Если передать ему `north/ops`, косая черта останется, и маршрут изменится. Эта рекомендация популярна из-за названия функции, но область ее применения здесь другая.

Путь действительно может содержать несколько сегментов, например если API задает `/{owner}/{repository}`. Представьте это двумя полями, а не одной свободной строкой `resourcePath`. Если endpoint должен принимать непрозрачный идентификатор с косыми чертами, лучше поместить его в query-параметр или JSON-тело. API, которые пропускают непрозрачный текст через маршрутизацию, заставляют всех гадать, как ведут себя закодированные косые черты.

Есть и решение на уровне маршрутизации, которое клиентское кодирование не исправит. Многие прокси и фреймворки нормализуют точки `.` и `..`, схлопывают повторяющиеся косые черты или отклоняют закодированные разделители. Задайте владельцу endpoint один прямой вопрос: сопоставляется ли маршрут до процентного декодирования или после него? Затем протестируйте развернутый маршрут вместе с прокси. Документация фреймворка, запущенного отдельно на компьютере разработчика, не отвечает на этот вопрос.

## Для query-строк нужна объявленная грамматика

Query-строка не является одним большим экранированным блоком. Это набор полей, чья грамматика принадлежит API. До безопасного вызова endpoint агентом нужно определить повторяющиеся имена, пустые значения, массивы, логические значения, пробелы и обработку дубликатов.

WHATWG URL Standard и браузерные API используют для `URLSearchParams` сериализацию query в стиле форм. В этой схеме пробел часто превращается в `+`, а настоящий плюс в `%2B`. Многие серверные парсеры используют тот же подход. Сам RFC 3986 не говорит, что `+` означает пробел в общем URI. Обе детали важны, когда один компонент применяет общий парсер, а другой парсер форм.

Используйте построитель query, а не шаблоны строк:

```javascript
const query = new URLSearchParams();
query.set("q", userText);
query.set("include_archived", "false");
for (const label of labels) query.append("label", label);

const url = "https://api.example.test/search?" + query.toString();
```

Для `userText = "C++ & systems"` точная запись может выглядеть как `q=C%2B%2B+%26+systems`. Сервер, применяющий декодирование форм, восстановит `C++ & systems`. В регрессионном тесте нужно проверять семантическое значение API, а не требовать от каждой библиотеки записи пробелов как `%20`. В используемых query-соглашениях и `%20`, и `+` могут обозначать пробел, но настоящий плюс после разбора должен остаться плюсом.

Для повторяющихся полей нужно принять явное решение. Это разные контракты:

```text
?label=bug&label=security
?label=bug,security
?label=["bug","security"]
```

Первый вариант содержит повторяющееся имя. Второй является одним значением с запятой, если API не говорит иначе. Третий похож на JSON-строку, но JSON не является, пока сервер специально ее не разберет. Не говорите агенту «передавай labels в URL», оставляя формат неявным. Дайте действию аргумент-массив и заставьте его сериализовать массив в единственной форме, которую принимает endpoint.

Повторяющиеся скалярные параметры тоже часто становятся источником ошибок. Запрос `?role=user&role=admin` может дать первое значение, последнее, массив или ошибку, в зависимости от фреймворка. Если проверка безопасности читает первое значение, а следующий сервис последнее, решения расходятся. Поля, которые должны встречаться один раз, отклоняйте при дублировании на самом раннем управляемом вами компоненте.

Стоит упомянуть и fragment, поскольку он мешает отладке. `#section` обычно не покидает клиент как часть HTTP-запроса. Если необработанный пользовательский ввод добавляет `#`, объект URL может убрать все, что стоит после него, еще до отправки. Когда символ является данными, кодируйте его внутри значения пути или query. Не пытайтесь понять, что получил сервер, по журналу исходной строки.

## Проценты и порядок декодирования создают разные идентификаторы

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

Возьмем текст `%252F`. После одного декодирования получится `%2F`, после второго `/`. Это важно, когда проверка выполняется между двумя операциями. Шлюз может отклонять обычный `/` в идентификаторе и разрешать `%252F`, а приложение выше по цепочке декодирует значение снова и разделяет маршрут. То же относится к `%252e`, которое превращается сначала в `%2e`, а затем в `.`.

В обсуждениях и тестах различайте три значения:

1. Исходная запись на проводе, например `%252F`.
2. Значение после одного процентного декодирования, например `%2F`.
3. Значение приложения после всех парсеров и переписываний, например `/`.

Команды часто называют все три «URL». Такая размытая терминология приводит к плохим ревью: люди сравнивают разные этапы и не замечают этого.

RFC 3986 советует создателям URI не кодировать и не декодировать одну и ту же строку больше одного раза. Это разумно, но не готовый план реализации. Определите, что сериализатор действия принимает декодированные строки приложения, и где сервер принимает исходные байты запроса. Все компоненты между этими границами должны сохранять экранирование или отклонять формы, которые не могут сохранить.

Не принимайте уже закодированный ввод «для удобства». Инструкция агента «передай URL-кодированный ID проекта» заставляет модель гадать, являются ли `%2F` данными или командой. Следующий слой не знает, нужно ли сохранить знак процента или закодировать его как `%25`. Принимайте обычные текстовые поля, кодируйте их один раз на уровне действия и отклоняйте некорректные процентные escape-последовательности только там, где намеренно принимаются исходные URL.

Unicode добавляет еще одну границу. Обычно URL-клиент превращает текст в байты UTF-8 и кодирует байты, которые нельзя напрямую поместить в выбранный компонент. Серверные фреймворки могут по-разному нормализовать Unicode перед поиском пользователя или ресурса. Если предметная область этого требует, храните идентификаторы в канонической форме на уровне приложения. Не пытайтесь решить проблему идентичности Unicode с помощью экранирования URL. Экранирование переносит байты, но не решает, обозначают ли две похожие на вид строки один аккаунт.

## Тестируйте весь маршрут на обычных, но сложных данных

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

Начните с контролируемого echo-обработчика в собственной тестовой среде. Он должен записывать исходный request target, если среда сервера его предоставляет, а затем возвращать разобранные путь и query-поля. Не помещайте в этот endpoint учетные данные. Его задача - показывать сериализацию, а не аутентифицировать кого-либо.

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

```javascript
import http from "node:http";

http.createServer((req, res) => {
  const url = new URL(req.url, "http://local.test");
  const pairs = [...url.searchParams.entries()];
  res.setHeader("content-type", "application/json");
  res.end(JSON.stringify({
    requestTarget: req.url,
    pathname: url.pathname,
    queryPairs: pairs
  }, null, 2));
}).listen(8787);
```

Отправляйте ему известные случаи и сохраняйте ожидаемый результат. Например, query-построитель, получивший `C++ & systems`, должен вернуть одну пару `q`, разобранное значение которой в точности равно `C++ & systems`. Вручную собранный query часто выдает себя двумя парами или превращает плюсы в пробелы.

```text
{
  "requestTarget": "/search?q=C%2B%2B+%26+systems",
  "pathname": "/search",
  "queryPairs": [["q", "C++ & systems"]]
}
```

Набор тестов должен покрывать компактную матрицу, а не десятки случайных строк:

- пробел, настоящий плюс, знак процента, амперсанд, знак равенства, вопросительный знак и решетка;
- косую черту и закодированную косую черту внутри идентификатора, который должен оставаться одним сегментом;
- пустые строки, отсутствующие необязательные поля и повторяющиеся query-имена;
- Unicode-значение и значение, чьи процентные escape-последовательности похожи на результат второго декодирования;
- полный URL, вставленный в поле, которое ожидает идентификатор.

Если команда уже использует property-based testing, применяйте его, но не прячьте названные случаи среди сгенерированных примеров. Эти случаи объясняют, зачем существует граница. При регрессии запись `encoded slash stays inside project_id` полезнее номера seed.

Запускайте те же интеграционные тесты через путь, которым пользуется production-трафик. Прямой тест процесса приложения не покажет, отклоняет ли прокси `%2F`, переписывает ли повторяющиеся косые черты и выбирает ли другое значение среди дубликатов query. Если production edge нельзя включить в локальные тесты, используйте staging-маршрут с той же конфигурацией и включите его проверку в release check.

## Давайте агентам структурированные аргументы, а не свободу собирать URL

Агент должен выбрать действие и передать типизированные аргументы. Реализация действия должна выбирать HTTP-метод, разрешенный origin, шаблон маршрута, грамматику query, заголовки и кодирование. Если позволить агенту передавать полный URL, все эти отдельные ограничения превращаются в одну неоднозначную строку.

Узкий контракт действия может выглядеть так:

```json
{
  "name": "get_project_builds",
  "input": {
    "project_id": "north/ops",
    "branch": "release+candidate",
    "limit": 25
  }
}
```

Код действия должен проверить `project_id` как идентификатор, закодировать его как один сегмент пути, поместить `branch` в query-построитель, проверить `limit` как целое в допустимом для API диапазоне и собрать URL из фиксированного origin. Модели не нужно видеть bearer-токен или решать, где должен стоять амперсанд.

Такое разделение также предотвращает путаницу с origin. Строке, начинающейся с `https://other.example`, не место в поле идентификатора. Если действие действительно загружает URL, предоставленный пользователем, сделайте это отдельным действием с письменным allowlist, правилами DNS и редиректов и ясным объяснением необходимости. Не прячьте возможность произвольной загрузки в поле с именем `callback` или `file`.

Будьте осторожны с API, которые принимают языки фильтрации в query-параметрах. Поле вроде `filter=status:open AND owner:me` имеет две грамматики: сериализацию URL и сам язык фильтра. Кодирование URL удерживает фильтр в одном query-значении, но не делает внутренний язык безопасным. Разбирайте или ограничивайте этот язык отдельно либо предложите типизированные поля фильтра.

Не помещайте секреты в URL. Query-строки могут попадать в access-логи, историю браузера, телеметрию и отчеты об ошибках. Учетные данные должны передаваться механизмом авторизации, который ожидает API. Кодирование API-токена не делает его присутствие в query безопасным.

## Подтверждение полезно, но не исправляет неправильный запрос

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

Это важно, когда кто-то утверждает, что промпты или карточки подтверждения делают строгую сборку запроса ненужной. Это не так. Чтобы понять, что произойдет с `https://api.example.test/projects/%252Fadmin`, проверяющему пришлось бы мысленно промоделировать каждый декодер на маршруте. Это несправедливый механизм безопасности, особенно если агент может сделать много вызовов за одну сессию.

Размещайте детерминированные проверки до границы подтверждения:

- принимайте структурированные поля, а не заранее собранный request target;
- фиксируйте origin, метод и шаблон маршрута в описании действия;
- сериализуйте каждый сегмент пути и query-значение один раз;
- отклоняйте повторяющиеся или некорректные поля, которых нет в контракте endpoint;
- тестируйте конечный target через развернутый маршрут запроса.

Sallyport может не раскрывать учетные данные MCP-совместимому агенту и требовать подтверждения для процесса агента или каждого использования выбранного секрета. Такой контроль человеком работает лучше, когда уровень действий уже создает один однозначный запрос.

Журналам тоже нужны оба уровня детализации. Записывайте одобренное действие и его безопасные аргументы, а затем храните редактированное представление фактического request target и HTTP-результата. Если query-поле чувствительное, скрывайте его значение, но сохраняйте имя параметра и достаточную структуру для диагностики случайного дублирования. Никогда не записывайте заголовки авторизации только потому, что запрос завершился ошибкой.

## Нормализация маршрута может свести на нет правильную работу клиента

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

Представьте endpoint, которому клиент отправляет такой путь:

```text
/files/reports%2F2025%2Fnotes
```

Если API ожидает один непрозрачный ID файла, приложению должно прийти значение `reports/2025/notes`. Но прокси, которое декодирует путь перед пересылкой, может отправить `/files/reports/2025/notes`. Маршрутизатор с правилом `/files/:id` отклонит такой запрос. Другой маршрутизатор может сопоставить `/files/:folder/:year/:name` и вызвать другой обработчик. Ни один из этих результатов сам по себе не доказывает ошибку клиентского кодировщика.

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

Следите и за редиректами. HTTP-клиенты могут автоматически переходить по ним, а перенаправленный URL может иметь иначе нормализованный путь или query. Для действий с учетными данными определите, разрешены ли редиректы, должен ли origin назначения совпадать и снимаются ли заголовки авторизации при смене origin. Это отдельная тема от процентного кодирования, но ошибки URL и правила редиректов часто встречаются в одном и том же коде запросов.

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

## Регрессионный набор должен сохранять задуманный смысл

Цель URL-тестов не в том, чтобы навязать один вариант написания escape-последовательностей. Она в сохранении связи между аргументами действия и смыслом запроса на стороне сервера при изменении библиотек и инфраструктуры.

Пишите проверки на трех уровнях. Unit-тесты должны подтверждать, что кодировщик сегментов превращает `a/b` в один закодированный сегмент, а сериализация query сохраняет `&` внутри значения. Тесты действия должны фиксировать точные метод, origin, путь и пары query, отправленные echo-обработчику. Интеграционные тесты должны проходить через production-подобный edge и проверять, что обработчик получил ожидаемые маршрут и параметры.

Если контракт API расплывчат, запишите неоднозначность и устраните ее. «Поддерживает текст поиска в URL» не является контрактом. Укажите, допустим ли пустой `q`, означает ли `q=a+b` плюс или пробел, разрешены ли повторяющиеся `tag` и можно ли использовать `%2F` в ID. Когда агент может создавать вызовы из любого текста человека, эти детали перестают быть второстепенными.

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

Журнал Activity в Sallyport помогает сопоставить одобренный вызов агента с возвращенным результатом, а цепочку аудита можно проверить офлайн командой `sp audit verify`. Используйте этот след для расследования расхождения, но не подменяйте им решение о том, какие данные принимает endpoint.

Первым я добавил бы до болезненного обычный тест: идентификатор с `/`, query-значение с `+` и `&`, а также знак процента, который должен остаться обычным символом. Если действие не может точно сказать, что получит сервер для таких входных данных, оно еще не готово принимать пользовательский текст от агента.
