Читать 7 мин

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

Ошибки кодирования URL могут отправить агента не на тот маршрут API или изменить данные query. Сначала тестируйте пути, query-строки, процентные escape-последовательности и специальные символы.

Ошибки кодирования 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-строк. Это различие объясняет большинство ошибок, которые бездумно называют «проблемами кодирования».

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

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

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

/projects/north/ops
/projects/north%2Fops

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

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

/search?q=USER_TEXT

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

/search?q=red&limit=500

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

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

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

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

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

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

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

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

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

Собирайте сегменты как данные, кодируйте каждый сегмент один раз и соединяйте только те разделители, которыми владеет маршрут. В 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, а не шаблоны строк:

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, и + могут обозначать пробел, но настоящий плюс после разбора должен остаться плюсом.

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

?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. Экранирование переносит байты, но не решает, обозначают ли две похожие на вид строки один аккаунт.

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

Отзывайте подозрительные запуски агентов
Журнал Sessions отслеживает запуски агентов и позволяет немедленно отозвать активную сессию.

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

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

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

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 часто выдает себя двумя парами или превращает плюсы в пробелы.

{
  "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, все эти отдельные ограничения превращаются в одну неоднозначную строку.

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

{
  "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 безопасным.

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

Отделите подтверждение от разбора URL
Sallyport отделяет подтверждение учетных данных от структурированной проверки запросов на уровне действий.

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

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

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

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

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

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

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

Отслеживайте каждый подтвержденный API-вызов
Журнал Activity записывает отдельные вызовы в зашифрованный журнал аудита с цепочкой хешей, недоступный для записи.

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

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

/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-значение с + и &, а также знак процента, который должен остаться обычным символом. Если действие не может точно сказать, что получит сервер для таких входных данных, оно еще не готово принимать пользовательский текст от агента.

Вопросы и ответы

Как проверить, правильно ли API-клиент закодировал URL?

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

Можно ли использовать одну функцию кодирования для путей и query-строк?

Нет. Сегмент пути обозначает одну часть иерархии ресурса, а query-компонент содержит пары имен и значений. Кодирование косой черты в пути имеет другие последствия, чем кодирование косой черты внутри значения параметра. Поэтому одна универсальная функция экранирования задает плохой контракт.

Почему знак плюса превращается в пробел в query-строке API?

В общей синтаксике URI, описанной в RFC 3986, знак плюса сам по себе не означает пробел. Однако многие парсеры query-параметров в стиле форм декодируют плюс как пробел из-за правил application/x-www-form-urlencoded. Если плюс должен остаться данными, кодируйте его как %2B.

Нужно ли кодировать косую черту в параметре пути как %2F?

Обычно косую черту, которая относится к пользовательским данным, нужно закодировать как %2F до помещения в один сегмент пути. Затем проверьте, что каждый компонент на маршруте запроса сохраняет ее, а не декодирует и не разделяет путь. Некоторые серверные стеки отклоняют закодированные косые черты, поэтому непрозрачный идентификатор безопаснее передавать в query-параметре или теле запроса.

Опасны ли значения URL, закодированные дважды?

Это может быть опасно. Один декодер превращает %252F в %2F, а второй превращает результат в косую черту и меняет маршрутизацию или проверку. Отклоняйте неожиданные escape-последовательности после однократного декодирования и тестируйте весь маршрут запроса, а не доверяйте одному компоненту.

Как агенту передавать массивы в query-строке?

?tag=a&tag=b обычно означает повторяющийся параметр, а ?tag=a,b является одним значением с запятой, если API не задает другое правило. Агент должен получать явно объявленный API-формат, а тесты должны проверять, как на сервер приходят пустые значения, повторяющиеся имена и порядок параметров.

Безопасно ли позволять AI-агенту собирать полный URL из пользовательского ввода?

Передавайте недоверенный текст в структурированных аргументах инструмента, а сериализацию каждого поля оставьте уровню действий. Не позволяйте агенту собирать полный URL конкатенацией строк. Так можно отклонить чужой origin, компонент user-info или неожиданный фрагмент до отправки запроса.

Что записывать в журнал при отладке ошибок кодирования URL?

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

Может ли подтверждение человеком предотвратить атаки через кодирование URL?

Подтверждение показывает, что процессу разрешено выполнить вызов. Оно не показывает, превратится ли %252e%252e%252f после двойного декодирования прокси в другой путь. Поэтому проверка построения запроса должна выполняться детерминированно до границы подтверждения.

Какие специальные символы включать в регрессионные тесты кодирования URL?

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

Sallyport

Sallyport выполняет API-вызовы и SSH-команды за вашего ИИ-агента. Ключи остаются в локальном хранилище на вашем Mac; вы подтверждаете каждый запуск, и каждое действие попадает в запечатанный журнал.

© 2026 Sallyport · Открытый код по лицензии Apache-2.0 · Oleg Sotnikov