# Почему GET-эндпоинтам, изменяющим данные, нужно одобрение на запись?

GET-эндпоинт, который меняет состояние, это операция записи в чужой форме. Опасность вполне реальна. Браузеры, краулеры, API-клиенты, средства мониторинга, сервисы предпросмотра ссылок, кэши и агенты выполняют дополнительные GET-запросы, потому что протокол говорит им: это безопасно. Если ваш эндпоинт отменяет задачу, обновляет токен, отправляет сообщение или меняет запись, такие лишние запросы могут превратиться в действия в рабочей среде.

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

## Безопасные HTTP-методы описывают смысл запроса

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

Это различие помогает распознать частую уловку: «Сервер должен обновлять last_seen, когда читает объект». Если клиент попросил получить объект, а сервис обновил внутреннюю метку времени доступа, это может быть случайным эффектом. Если клиент попросил получить объект, а сервис отметил счёт оплаченным, создал экспорт, использовал токен или продвинул рабочий процесс, изменение и было запрошенной операцией. Назовите её записью.

RFC 9110 также объясняет, почему это важно на практике. Пользовательские агенты могут автоматизировать безопасные методы. Браузер может загрузить страницу, чтобы построить превью. Краулер может перейти по ссылке. Клиентская библиотека может повторить запрос после потери ответа. Семантика протокола разрешает таким участникам вести себя именно так. Сервер не вправе рассчитывать, что каждый вызывающий знает о его недокументированном исключении.

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

- Безопасность показывает, запросил ли вызывающий изменение состояния.
- Идемпотентность показывает, даёт ли повторение того же запроса тот же ожидаемый результат.
- Кэшируемость показывает, может ли посредник повторно использовать ответ.

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

## Ищите последствия, а не подозрительные имена маршрутов

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

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

Такой простой поиск обнаруживает много старого кода:

```sh
rg -n 'GET|\.get\(|router\.get\(|app\.get\(' src
rg -n 'INSERT|UPDATE|DELETE|enqueue|publish|sendMail|charge|revoke|rotate' src
```

Форма вывода менее полезна, чем запись для проверки, которую вы создадите на его основе. Для каждой находки укажите эндпоинт, триггер, конечный эффект, затронутую систему и список вызывающих сторон. Не пишите в колонке эффекта «обновляет статус». Напишите: «отмечает развёртывание d-481 отменённым и передаёт отмену планировщику». Расплывчатые формулировки позволяют проверяющему пропустить серьёзное действие.

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

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

## Устаревшие API прячут записи в привычных местах

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

Ссылки подтверждения сброса пароля это классический случай. Маршрут вроде `GET /reset/confirm?token=...` выглядит удобным, потому что его может открыть браузер. Если открытие URL расходует токен и меняет пароль, почтовые сканеры и средства предпросмотра могут израсходовать его раньше пользователя. Безопасный дизайн использует GET, чтобы показать состояние подтверждения, ничего не расходуя, а затем POST, чтобы отправить подтверждение. Страница может содержать короткоживущую серверную ссылку, но запись происходит только после явного действия.

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

Среди других частых нарушителей:

- `GET /jobs/123/retry`, который создаёт новый запуск при каждом обновлении панели.
- `GET /deployments/123/rollback`, который проверка мониторинга может вызвать при тестировании ссылок.
- `GET /tokens/123/revoke`, который превращает URL службы поддержки в разрушительную возможность.
- `GET /invoices/123/send`, который превращает бота предпросмотра в отправителя почты.
- `GET /reports/monthly`, который незаметно запускает дорогой экспорт вместо возврата готового.

Популярный совет «просто требуйте секретный параметр запроса» ошибочен. Строки запроса попадают в историю браузера, аналитику, журналы сервера, заголовки Referer в некоторых сценариях, снимки экрана и пересланные сообщения. Что ещё важнее, секретный URL всё ещё остаётся GET-URL. Любой человек или процесс, получивший его, может активировать действие без границы одобрения.

## Повторы и превью расширяют радиус поражения

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

Представьте устаревший эндпоинт, который перезапускает удалённую сборку при получении `GET /builds/77/retry`. Агент получает URL по сетевому пути, где истекает тайм-аут уже после того, как сервер принял запрос. Агент делает то, что делают многие HTTP-клиенты, и повторяет попытку. Исходный запрос уже поставил сборку 311 в очередь, второй ставит в очередь сборку 312. Затем панель загружает ссылку предпросмотра в ленте активности и ставит в очередь сборку 313. Обработчик может каждый раз вернуть `200 OK`, поэтому ответ никак не показывает, что операция продублировалась.

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

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

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

## Дайте действиям контракт, похожий на запись

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

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

```http
POST /v1/builds/77/retries HTTP/1.1
Idempotency-Key: 9ef8b462-97bf-4ca3-bb8b-4396a60ed9ae
Content-Type: application/json

{"reason":"retry after failed dependency download"}
```

```http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/builds/311

{"id":"311","source_build":"77","state":"queued"}
```

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

Используйте PUT или PATCH, когда запрос описывает желаемое состояние ресурса. `PATCH /v1/deployments/77` с `{\"paused\":true}` уместен, если это поле принадлежит ресурсу. `POST /v1/deployments/77/rollback` точнее описывает команду с новым запуском, аудит-следом и возможным асинхронным результатом. Не втискивайте команду в PATCH только ради соответствия чьему-то руководству по стилю.

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

## Одобрение должно происходить до внедрения учётных данных

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

Это важно, когда API вызывает ИИ-агент для программирования. Агент может решить, что маршрут читает данные, на основе описания инструмента, скопировать старый URL из репозитория или последовать совету из задачи. При наличии исходных учётных данных он способен выполнить вызов до того, как человек увидит эндпоинт. Указание модели быть осторожной не заменяет контроль авторизации.

Дайте слою одобрения нормализованную модель действия. Она должна содержать как минимум HTTP-метод, хост, путь, идентификатор цели, если он есть, и краткое описание последствия. Слой должен классифицировать действия по сервисному контракту, а не только по `method === "GET"`. Устаревший GET-маршрут, вызывающий `revokeToken`, должен проходить через тот же путь одобрения, что и `POST /tokens/123/revoke`, пока вы его не удалите.

Практическое сопоставление может выглядеть так:

```json
{
  "method": "GET",
  "url": "https://api.example.test/v1/tokens/tk_42/revoke",
  "semantic_action": "revoke credential",
  "target": "tk_42",
  "approval": "required",
  "reason": "legacy GET endpoint changes remote credential state"
}
```

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

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

## Пусть чтение остаётся полезным, а запись трудно запустить случайно

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

Для генератора отчётов `GET /reports/monthly` может вернуть последний готовый отчёт и текущий статус генерации. `POST /reports/monthly/runs` запускает новую генерацию. Для операции с учётными данными `GET /tokens/tk_42` может вернуть метаданные, а `POST /tokens/tk_42/revocations` создаёт событие отзыва. Дополнительный сегмент пути менее хитроумен, чем параметр действия в запросе, но делает журналы, клиентов и экраны проверки гораздо понятнее.

Сухой запуск требует точного контракта. `POST /deployments/77/rollback?dry_run=true` всё ещё POST, потому что вызывающая сторона запросила оценку команды, даже если она ничего не фиксирует. Верните запланированные цели, ожидаемые предусловия и все неразрешённые значения. Не заставляйте `GET /rollback?preview=true` выполнять планирование команды, если само планирование захватывает блокировки, резервирует мощности или обращается к провайдеру с заметным эффектом.

Некоторые команды пытаются сохранить старые интеграции, заставляя старый GET возвращать HTML-страницу с формой, которая автоматически отправляет POST. Это лишь переносит риск в браузер. Используйте страницу, требующую реального взаимодействия с пользователем, и защищайте POST подходящими для приложения мерами same-origin. API-клиентам нужен понятный ответ об устаревании и срок миграции, а не браузерный документ, которым они не могут воспользоваться.

## Тестируйте вызывающие стороны, которые никогда не спрашивают разрешения

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

Для каждого перенесённого действия выполните в изолированной среде такие проверки:

1. Дважды отправьте старый GET и убедитесь, что он не может создать два действия. Во время перехода он должен безопасно завершаться ошибкой, показывать только состояние подтверждения или возвращать ответ об устаревании.
2. Смоделируйте клиента, который теряет ответ после отправки, затем повторяет POST с тем же ключом идемпотентности. Убедитесь, что сервис возвращает исходный идентификатор действия.
3. Многократно получите безопасный URL статуса и убедитесь, что он не создаёт задач, сообщений, записей в реестре или внешних вызовов.
4. Попытайтесь выполнить действие с истёкшим одобрением или отозванной сессией и убедитесь, что запрос вообще не доходит до удалённого сервиса.
5. Проверьте запись аудита и убедитесь, что она идентифицирует нормализованное действие, а не только транспортный маршрут.

Используйте инъекцию сбоев в неудобной точке: после того, как сервер зафиксировал действие, но до отправки ответа. Именно там команды понимают, повторяет ли их клиент запросы вслепую. Если единственная стратегия восстановления звучит как «попробуйте ещё раз», контракт не даёт вызывающей стороне достаточно информации.

Также проверяйте примеры в документации. Команда curl, скопированная в канал во время инцидента, становится операционным интерфейсом. Если в примере используется GET, потому что он помещается в одну строку, кто-то его автоматизирует. Сделайте пример безопасного чтения и пример явного действия заметно разными.

## Аудитируйте эффект, а не только маршрут

Строка аудита вида `GET /v1/builds/77/retry 200` даёт слабое доказательство. Она фиксирует факт транспорта, но скрывает бизнес-событие. Во время инцидента расследующему всё ещё приходится восстанавливать, запустил ли вызов сборку, повторил прежнюю или лишь вернул её состояние.

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

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

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

## Уберите исключение, а не документируйте его вечно

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

Начните с эндпоинта, который может привести к самому необратимому результату. Добавьте явный POST-контракт, поведение идемпотентности, классификацию одобрения и запись аудита на уровне эффекта. Затем сделайте каждый старый GET-вызов наблюдаемым. Когда вы сможете назвать оставшихся вызывающих, переводите их целенаправленно, а не ломайте скрытую интеграцию неожиданно.

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