diff --git a/editorial/agent-rewrites/011.json b/editorial/agent-rewrites/011.json index 394833d..d8f74c6 100644 --- a/editorial/agent-rewrites/011.json +++ b/editorial/agent-rewrites/011.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", - "contentHtml": "
Сервис принимает запрос на изменение заказа. JSON проходит проверку схемы: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.
Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, имеет ли документ допустимую форму. Чистая доменная проверка отвечает, согласованы ли его поля. Сервис проверяет, можно ли применить документ к текущему состоянию и имеет ли пользователь право на действие. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке не нужны база, часы, права или другой запрос, её можно держать на границе документа. Как только появляется внешний контекст, это уже правило операции.
\nПервый вопрос относится к форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа она всегда возвращает одинаковый ответ.
Второй вопрос относится к связи полей. Например, диапазон дат требует, чтобы from не был позже to. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Это правило можно выразить в схеме, если выбранный диалект и валидатор поддерживают нужную конструкцию. На практике его часто проще вынести в чистую функцию с отдельным тестом. Важно не место записи, а ясная граница ответственности.
Третий вопрос относится к состоянию. Документ может быть безупречным, но ресурс уже изменился. Версия revision: 4 устарела, купон исчерпан, а роль пользователя не позволяет перевести заказ в новый статус. Такой отказ нельзя исправить изменением JSON-типа. Клиенту нужно перечитать ресурс, показать конфликт или прекратить операцию.
| Слой | Что проверяем | Пример отказа | Где выполнять |
|---|---|---|---|
| Форма | Тип, обязательность, диапазон, enum | limit не является integer | JSON Schema или валидатор на входе |
| Инвариант | Связь нескольких полей | from позже to | Чистая доменная функция |
| Состояние | Актуальность и доступность ресурса | Ревизия уже изменилась | Сервис, репозиторий, транзакция |
| Право | Разрешение на операцию | Роль не может отменить заказ | Авторизация до изменения состояния |
Схема полезна там, где нужно зафиксировать форму документа и дать одинаковую проверку нескольким потребителям. Она задаёт типы, обязательные ключи, диапазоны, шаблоны, перечисления и структуру вложенных объектов. Она также делает контракт читаемым для инструментов. OpenAPI 3.1 использует модель Schema Object, совместимую с JSON Schema 2020-12 с оговорёнными изменениями, поэтому форму HTTP-запроса удобно держать рядом с описанием endpoint.
\nСхема не знает, что запись существует именно сейчас. Она не проверяет доступ к строке базы, не сравнивает версию с версией в хранилище и не гарантирует, что внешний сервис ответил успешно. Даже формат даты не равен бизнес-смыслу даты: строка может быть корректной датой, но лежать за пределами периода, в котором разрешена операция.
\nНеизвестные поля требуют отдельного решения. Закрытая схема с запретом лишних ключей ловит опечатку, но может помешать расширению контракта. Открытая схема облегчает добавление полей, но пропускает ошибочный ключ, который клиент потом молча проигнорирует. Выберите модель для конкретного endpoint и закрепите её тестом. Не меняйте это поведение случайно при обновлении валидатора.
\nНиже — самостоятельный учебный фрагмент без HTTP-сервера и базы данных. Он показывает порядок вычислений, а не готовый production-код. Первая функция проверяет форму фильтра. Вторая проверяет связь полей. Обе функции чистые: их результат зависит только от переданного объекта.
\nconst filterSchema = {\n type: 'object',\n additionalProperties: false,\n properties: {\n limit: { type: 'integer', minimum: 1, maximum: 100 },\n from: { type: 'string', format: 'date' },\n to: { type: 'string', format: 'date' }\n }\n};\n\nfunction validateRange(input) {\n if (input.from && input.to && input.from > input.to) {\n return { ok: false, reason: 'from-after-to' };\n }\n return { ok: true };\n}\n\nconst shapeIsValid = validateWithSchema(filterSchema, {\n limit: 25, from: '2027-09-10', to: '2027-09-12'\n});\nconst rangeIsValid = validateRange({\n from: '2027-09-12', to: '2027-09-10'\n});\nВ примере validateWithSchema обозначает вызов выбранной библиотекой JSON Schema. Это намеренное сокращение: конкретные API библиотек различаются, а задача фрагмента — показать две границы. Первый вызов отвечает за форму. Второй не пытается читать ресурс и не решает, есть ли право на поиск. В учебных данных нет утверждения о производительности или поведении в production.
Для изменения ресурса добавляется третий шаг. Клиент отправляет ожидаемую ревизию. Сервис читает текущую ревизию внутри транзакции и применяет изменение только при совпадении. Если в хранилище уже другая версия, сервис возвращает конфликт. Простая проверка до транзакции не защищает от гонки: другой запрос может изменить запись после чтения.
\nasync function updateOrder(command, actor) {\n const shape = validateCommandShape(command);\n if (!shape.ok) return httpError(400, shape.reason);\n\n const domain = validateCommandInvariant(command);\n if (!domain.ok) return httpError(422, domain.reason);\n\n if (!canEditOrder(actor, command.orderId)) {\n return httpError(403, 'forbidden');\n }\n\n return db.transaction(async (tx) => {\n const order = await tx.orders.getForUpdate(command.orderId);\n if (!order || order.revision !== command.expectedRevision) {\n return httpError(409, 'state-conflict');\n }\n return tx.orders.update(command.orderId, command.patch);\n });\n}\nЭтот код тоже ограничен учебной моделью. В реальной системе нужно проверить семантику блокировки, изоляцию транзакции, повторяемость команды и формат ошибки. Нельзя переносить фрагмент в production без проверки конкретного драйвера и правил домена.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Валидатор принимает ключ, а клиент его не использует | Схема открыта или контракт не обновили | Сверить unknown-key policy и чтение поля | Закрыть объект либо явно документировать расширение |
| Правильный JSON получает 400 | Ошибка состояния скрыта под ошибкой формы | Разделить логи shape, invariant и state | Вернуть отдельный класс ошибки и исправить retry |
| Повторный запрос иногда меняет уже изменённый ресурс | Нет идемпотентности или проверки ревизии | Повторить команду при конкурентном обновлении | Добавить idempotency key или условие версии |
| Тест схемы проходит, endpoint падает | Схема не покрывает runtime-ветку | Вызвать реальный handler с теми же данными | Добавить интеграционный тест на границе |
| Клиент бесконечно повторяет запрос | Конфликт обозначен как временная ошибка | Проверить статус и тело ответа | Различить retryable отказ и конфликт ресурса |
validate: сначала определите документ.Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от времени и нескольких систем. В таких случаях нужно явно описать границу и риск, а не расширять JSON Schema до роли универсального движка правил.
\nСтатус HTTP тоже не заменяет доменный контракт. 409 Conflict подходит для конфликта с текущим состоянием ресурса, но тело ответа должно объяснить, что проверил сервис и что может сделать клиент. 422 может обозначать семантически неприемлемый документ, если это решение согласовано в API. Важна не магическая цифра, а стабильное различие между исправлением входа и разрешением конфликта.
Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат уже скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, а конкурентное изменение ресурса не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов.
\nРешение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, авторизация отклоняет запрещённого актёра, а транзакция отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий тоже проходит через тот же порядок. Если один из этих вопросов пока отвечает только фразой «валидатор всё проверит», граница ещё не проведена.
\nПроблема начинается с запроса на изменение заказа. JSON проходит проверку: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.
Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, соответствует ли экземпляр описанной форме. Доменная проверка отвечает, согласованы ли поля между собой. Сервис проверяет текущий ресурс, полномочия и возможность применить команду. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке нужен только сам документ, её можно выполнить на входе. Как только нужны база, часы, права или другой запрос, это уже правило операции с отдельным результатом и отдельным тестом.
\nНачните с вопроса о форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа и одинакового выбранного диалекта она должна давать одинаковый результат.
Затем проверьте связь полей. Диапазон дат требует, чтобы from не был позже to; сумма платежа должна соответствовать разрешённому типу операции; поле currency должно быть совместимо с суммой. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Часть таких условий выражается в JSON Schema через композицию и условные конструкции, если их поддерживает выбранный диалект и валидатор. Чистая функция часто читается лучше. Место проверки вторично по сравнению с тем, что правило названо и тестируется отдельно.
Наконец, проверьте применимость к состоянию. Документ может быть безупречным, но ресурс уже изменился: revision: 4 устарела, купон исчерпан или заказ перешёл в необратимый статус. Это не ошибка типа JSON. Клиенту нужно перечитать ресурс, показать конфликт или завершить сценарий без повторной отправки.
| Слой | Что проверяем | Пример отказа | Где выполнять |
|---|---|---|---|
| Форма | Тип, обязательность, диапазон, enum | limit не integer | JSON Schema или валидатор на входе |
| Инвариант | Связь нескольких полей | from позже to | Чистая доменная функция |
| Состояние | Актуальность и доступность ресурса | Ревизия уже изменилась | Репозиторий внутри согласованной операции |
| Право | Разрешение на команду | Роль не может отменить заказ | Политика авторизации до изменения состояния |
Схема фиксирует структуру экземпляра: типы, обязательные ключи, диапазоны, шаблоны, перечисления и вложенные объекты. Она полезна как исполняемый контракт для одинаковой проверки разных потребителей и как документация, которую могут использовать инструменты. Но результат означает только соответствие экземпляра описанным ограничениям. Из него не следует, что заказ существует сейчас, пользователь имеет доступ или внешняя система согласилась на операцию.
\nСлово «формат» требует осторожности. В JSON Schema Draft 2020-12 vocabulary format разделён на аннотацию и assertion: реализация может собирать информацию о формате, но не обязана отклонять экземпляр только из-за неё, если не включён режим проверки форматов. Поэтому для дат, URI и email нужно зафиксировать диалект, библиотеку и настройки. Если дата важна для домена, дополните схему явным правилом и тестом, а не рассчитывайте на одинаковое поведение всех валидаторов.
Неизвестные поля требуют отдельного решения. Закрытый объект с additionalProperties: false ловит опечатку, но усложняет расширение контракта. Открытый объект легче развивать, но ошибочный ключ может быть молча проигнорирован потребителем. Выберите политику для конкретного endpoint, внесите её в схему и проверьте отрицательным примером. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12, но является его надмножеством с собственным диалектом; не переносите предположения о поведении одной реализации в другую.
Ниже — запускаемый учебный фрагмент без HTTP-сервера, базы и внешних пакетов. Объект filterSchema показывает намерение контракта, а небольшая функция имитирует только нужный для примера набор проверок. Это не реализация JSON Schema и не замена библиотеке: цель фрагмента — сделать видимым порядок «форма → инвариант» и дать отрицательные случаи, которые можно воспроизвести командой node validation-example.mjs.
import assert from 'node:assert/strict';\n\nconst filterSchema = {\n type: 'object',\n additionalProperties: false,\n properties: {\n limit: { type: 'integer', minimum: 1, maximum: 100 },\n from: { type: 'string', format: 'date' },\n to: { type: 'string', format: 'date' }\n }\n};\n\nconst isIsoDate = (value) => {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n const parsed = new Date(`${value}T00:00:00Z`);\n return parsed.toISOString().slice(0, 10) === value;\n};\n\nfunction validateShape(input) {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, reason: 'object-required' };\n }\n const allowed = new Set(Object.keys(filterSchema.properties));\n if (Object.keys(input).some((key) => !allowed.has(key))) {\n return { ok: false, reason: 'unknown-property' };\n }\n if ('limit' in input && (!Number.isInteger(input.limit)\n || input.limit < 1 || input.limit > 100)) {\n return { ok: false, reason: 'limit-out-of-range' };\n }\n for (const key of ['from', 'to']) {\n if (key in input && !isIsoDate(input[key])) {\n return { ok: false, reason: `${key}-must-be-date` };\n }\n }\n return { ok: true };\n}\n\nfunction validateRange(input) {\n if (input.from && input.to && input.from > input.to) {\n return { ok: false, reason: 'from-after-to' };\n }\n return { ok: true };\n}\n\nassert.deepEqual(validateShape({\n limit: 25, from: '2027-09-10', to: '2027-09-12'\n}), { ok: true });\nassert.equal(validateShape({ limit: 0 }).ok, false);\nassert.equal(validateShape({ typo: 25 }).reason, 'unknown-property');\nassert.equal(validateRange({\n from: '2027-09-12', to: '2027-09-10'\n}).reason, 'from-after-to');\nconsole.log('shape and invariant checks passed');\nЗдесь сравнение дат безопасно только потому, что isIsoDate сначала проверяет календарную дату и приводит её к единому формату. Для более сложных календарей, часовых поясов и локального времени правило нужно заменить доменной библиотекой и тестами. В настоящем сервисе структурную часть передайте выбранному JSON Schema-валидатору, явно включите нужную проверку format, а функцию диапазона оставьте чистой и вызывайте после успешной проверки формы.
Проверка ревизии должна быть частью операции изменения, а не отдельным предварительным запросом. Если сначала прочитать заказ, затем проверить revision и только потом записать данные, второй клиент успеет изменить заказ между этими действиями. Получится классическая гонка: оба запроса увидели старое состояние, хотя применить можно было только один.
Практический вариант — передавать ожидаемую ревизию и делать условное обновление внутри транзакции: обновить запись только при совпадении идентификатора и версии, увеличить версию атомарно, а отсутствие обновлённой строки превратить в конфликт. Точный SQL, блокировки и уровень изоляции зависят от СУБД. Поэтому пример с getForUpdate нельзя копировать без проверки драйвера, а тест должен запускать два конкурентных изменения, а не только вызывать функцию два раза подряд.
Право — ещё один отдельный источник контекста. Ответ 403 говорит, что сервер понял запрос, но не разрешает действие этому субъекту; он не исправляется изменением revision. Если политика зависит от владельца, организации или состояния заказа, проверяйте её на том же представлении данных, для которого принимается решение. Не полагайтесь на скрытие кнопки в интерфейсе: клиент не является границей доверия.
Для конфликта текущего ресурса RFC 9110 определяет 409 Conflict и ожидает от сервера достаточно сведений, чтобы распознать источник конфликта. Для HTTP-precondition, заданного заголовками вроде If-Match, применяется отдельная семантика 412 Precondition Failed. Выбранный статус должен соответствовать реальному контракту API, а тело — содержать стабильный код причины и безопасное действие: перечитать ресурс, объединить изменения или прекратить повтор.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Валидатор принимает ключ, а клиент его не использует | Схема открыта или контракт не обновили | Сверить политику unknown keys и чтение поля | Закрыть объект либо документировать расширение |
| Правильный JSON получает 400 | Ошибка состояния скрыта под ошибкой формы | Разделить логи shape, invariant и state | Вернуть отдельный класс ошибки и исправить retry |
| Повторный запрос меняет уже изменённый ресурс | Нет идемпотентности или проверки версии | Повторить команду при конкурентном обновлении | Добавить ключ идемпотентности или условие версии |
| Тест схемы проходит, endpoint падает | Схема не покрывает runtime-ветку | Вызвать реальный handler с теми же данными | Добавить интеграционный тест на границе |
| Клиент бесконечно повторяет запрос | Конфликт обозначен как временная ошибка | Проверить статус, код причины и retry policy | Различить повторяемый отказ и конфликт ресурса |
validate: сначала определите документ и команду.format в выбранном валидаторе. Один и тот же ключ может быть аннотацией в одной конфигурации и отклонением в другой.Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от нескольких систем и измениться между проверкой и записью. В таких случаях нужно описать границу согласованности и риск, а не расширять JSON Schema до роли универсального движка правил.
\nHTTP-статус тоже не заменяет доменный контракт. 409 подходит для конфликта с текущим состоянием ресурса, но не обязан быть единственным выбором для каждого бизнес-отказа. 422 уместен, когда сервер понимает тип содержимого и не может обработать содержащиеся инструкции; конкретное применение согласуйте в API. Важна стабильная связь «причина → статус → действие», а не магическая цифра.
Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, право проверяется сервером, а конкурентное изменение не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов, ошибок интеграции или неправильной бизнес-модели.
\nРешение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, политика авторизации отклоняет запрещённого субъекта, а атомарное изменение отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий проходит через тот же порядок. Если на любой вопрос команда отвечает только «валидатор всё проверит», граница ещё не проведена.
\nformat. Используйте её для формы JSON; состояние ресурса и права остаются за пределами схемы.409 Conflict, 412 Precondition Failed и 422 Unprocessable Content. Применяйте его как опору для статусов, а не как замену доменному контракту.Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки растёт быстро: приходится искать все версии клиента, откатывать серверный код и решать, не потеряны ли уже записи, созданные по новой схеме.
\\nПричина обычно не в синтаксической ошибке JSON. Команда меняет форму ответа как внутреннюю модель и не замечает потребителей. Она удаляет поле, делает новое поле обязательным, меняет тип или добавляет значение в enum. Каждый такой diff имеет собственный риск. Тезис простой: API-ответ нужно проверять как контракт двух сторон — по форме, по поведению старого клиента и по условиям, которые схема не описывает.
\\nКонтракт начинается с конкретной границы: метод, путь, статус, media type и тело. Для GET /customers/{id} можно зафиксировать объект с обязательными полями id, revision и state. У id строковый тип. У revision положительное целое число. У state закрытый набор значений active и blocked.
Эта форма отвечает на вопрос «можно ли разобрать JSON». Она не отвечает на вопросы «имеет ли пользователь право видеть клиента» и «не устарела ли ревизия записи». Эти проверки относятся к авторизации и состоянию. Если смешать их со схемой, ответ об ошибке станет неточным: клиент не поймёт, нужно ли исправить запрос, обновить данные или прекратить повторные попытки.
\\nGET /customers/{id}\\nAccept: application/json\\n\\n200 OK\\nContent-Type: application/json\\n\\n{\\n "id": "customer-17",\\n "revision": 4,\\n "state": "active"\\n}\\nУспешный ответ не становится совместимым только потому, что его принимает парсер JSON. Клиент может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Сохранить синтаксис недостаточно: нужно сохранить значения и смысл, на которые опирается старый код.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый клиент получает ошибку при чтении поля | Поле удалили или изменили его тип | Сравнить старую и новую схему и найти чтения поля | Сохранить поле или выпустить новую версию |
| Клиент попадает в ветку «неизвестное состояние» | Enum расширили без обработки нового значения | Прогнать старый switch на каждом значении | Добавить обработку либо не включать значение в старый контракт |
| Запросы начинают отклоняться после обновления | Новое поле объявили обязательным | Отправить старую форму без поля | Сделать поле необязательным или изменить версию |
| Клиент принимает ответ, но действует по неверной ветке | Сохранили тип, но изменили смысл значения | Проверить примеры поведения, а не только JSON Schema | Сохранить семантику или переименовать поле |
| Ответ формально верен, но операция получает отказ | Нарушено право или текущее состояние ресурса | Проверить авторизацию и условие версии отдельно | Вернуть точный 403/409 и не маскировать его под 400 |
Добавление необязательного поля чаще всего совместимо: старый клиент его игнорирует. Но это правило действует только для потребителя, который действительно игнорирует неизвестные свойства. У строгого декодера или схемы с запретом дополнительных полей появится отказ. Поэтому решение принимают по реальным правилам клиента, а не по названию изменения.
\\nСледующая функция показывает минимальную проверку ответа. Она не ходит в сеть и не читает базу. Входом служит уже разобранный JavaScript-объект. Функция принимает только известную форму и возвращает нормализованное значение. Это учебный пример: он показывает границу контракта, но не заменяет OpenAPI, JSON Schema, интеграционный тест или авторизацию.
\\nfunction validateCustomerResponse(payload) {\\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\\n return { ok: false, reason: 'body-must-be-object' };\\n }\\n\\n if (typeof payload.id !== 'string' || payload.id.length === 0) {\\n return { ok: false, reason: 'id-must-be-non-empty-string' };\\n }\\n\\n if (!Number.isInteger(payload.revision) || payload.revision < 1) {\\n return { ok: false, reason: 'revision-must-be-positive-integer' };\\n }\\n\\n if (!['active', 'blocked'].includes(payload.state)) {\\n return { ok: false, reason: 'state-is-outside-enum' };\\n }\\n\\n return {\\n ok: true,\\n value: { id: payload.id, revision: payload.revision, state: payload.state },\\n };\\n}\\n\\nconst accepted = validateCustomerResponse({\\n id: 'customer-17', revision: 4, state: 'active',\\n});\\nconst rejected = validateCustomerResponse({\\n id: 'customer-17', revision: 4, state: 'deleted',\\n});\\n\\nconsole.log(accepted.ok, accepted.value.state);\\nconsole.log(rejected.ok, rejected.reason);\\n// true active\\n// false state-is-outside-enum\\nОтдельный отрицательный пример важнее ещё одного успешного fixture. Если сервер начнёт отправлять state: deleted, валидатор обнаружит изменение до того, как клиент выполнит неверную ветку. Если сервер отправит revision: "4", отказ произойдёт по типу. Если поле исчезнет, причина должна назвать поле, а не скрыться за общим сообщением invalid response.
Схема хорошо описывает типы, обязательность и ограничения документа. Она может запретить лишние поля или определить ветвление по значению. Но она не видит пользователя, базу и время. Ответ state: active может быть синтаксически правильным, хотя запись уже заблокирована. Значение revision: 4 не доказывает, что обновление с ревизией 3 ещё допустимо.
Состояние требует отдельного протокола. Для конкурентного обновления подойдут версия ресурса и условный запрос с If-Match; для права — проверка роли до изменения; для отсутствующего ресурса — договорённый статус 404. Не превращайте 409 в 400: клиенту нужен сигнал, что запрос сформирован правильно, но состояние изменилось. Не повторяйте 403 автоматически: повтор не добавит прав.
Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении. Поэтому contract test должен вызвать маршрут, проверить статус, заголовок и тело. Runtime-проверка должна работать на фактическом ответе, а не только на вручную собранном объекте. Это снижает конкретный риск, но не доказывает, что список потребителей полон.
\\nУчебная функция не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои и содержимое базы. Она также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.
\\nИзменение готово к выпуску, если команда может показать четыре доказательства: новая форма проходит schema- и runtime-проверку; старый клиент проходит consumer contract test; отрицательные случаи возвращают согласованные статусы и причины; для breaking change указаны версия, период совместимости и проверяемое условие удаления. Если хотя бы одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно.
\\nСервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки быстро становится операционной: приходится искать версии клиента, решать, можно ли откатить сервер, и проверять, не появились ли уже записи по новой схеме.
\nПричина часто не в синтаксисе JSON. Команда меняет ответ как внутреннюю модель и не замечает внешних потребителей: удаляет свойство, меняет тип, сужает перечисление или объявляет новое свойство обязательным. Поэтому проверять нужно не только «валиден ли JSON», но и может ли прежний клиент разобрать ответ и выбрать ту же ветку поведения. Ниже — небольшой контракт для GET /customers/{id}, воспроизводимая проверка и критерий выпуска.
До обсуждения полей зафиксируйте операцию: метод, путь, допустимый статус, Content-Type и тело ответа. OpenAPI описывает HTTP-интерфейс так, чтобы его могли читать люди и инструменты; это удобный источник договорённости, но не телеметрия реального сервера. Если handler иногда отвечает HTML-страницей ошибки или другой схемой при том же статусе, один файл OpenAPI этого не обнаружит.
В примере успешное представление клиента имеет три обязательных поля. id — непустая строка, revision — положительное целое, state — одно из двух значений. Это именно внешний формат, а не копия таблицы в базе данных. Внутреннее поле updatedAt можно не публиковать; наоборот, публичное revision может быть вычисляемым. Такое разделение помогает не вынести внутреннюю миграцию наружу случайным изменением DTO.
GET /customers/{id}\nAccept: application/json\n\n200 OK\nContent-Type: application/json\n\n{\n "id": "customer-17",\n "revision": 4,\n "state": "active"\n}\nHTTP 200 говорит о результате операции на уровне протокола, но не обещает, что конкретная библиотека десериализации примет все значения. Клиент может строить URL из id, сравнивать ревизии или выбирать экран по state. Совместимость — это сохранение тех свойств и значений, на которые реально опирается старый потребитель.
Термин breaking change нельзя применять к любому diff. Риск зависит от направления обмена и поведения клиента. Добавление необязательного свойства в ответ обычно переживает tolerant-клиент, но строгий декодер может отклонить неизвестное поле. Добавление нового значения enum не меняет JSON-тип, однако ломает закрытый switch, если клиент не имеет безопасной ветки по умолчанию. Поэтому таблица ниже — матрица для проверки, а не автоматический вердикт для всех библиотек.
| Изменение | Обычный риск | Что проверить | Решение |
|---|---|---|---|
| Удалено свойство | Breaking | Чтение поля, мапперы и fixtures старых клиентов | Сохранить поле на период совместимости или выпустить версию |
| Тип изменён: строка стала числом | Breaking | Десериализация и сравнения в старом клиенте | Добавить новое свойство с новым типом |
| Добавлено обязательное свойство в ответ | Breaking для строгого декодера | Обработка отсутствия поля и правила схемы клиента | Согласовать режим декодера; при запрете неизвестных полей — сменить версию |
| Добавлено новое значение enum | Условный breaking | Ветки старого клиента на каждом значении | Расширить обработчик либо не отправлять значение старой версии |
| Добавлено необязательное свойство | Обычно совместимо | Запрет неизвестных полей и влияние на размер ответа | Оставить расширение и добавить consumer-тест |
| Изменён смысл прежнего значения | Скрытый breaking | Поведение, а не только JSON Schema | Сохранить смысл или переименовать поле |
Отдельно проверяйте статус и заголовки. Ответ 404, который превратился в 200 с объектом ошибки, может сломать клиент раньше, чем тот доберётся до тела. И наоборот, формально одинаковый JSON при смене семантики статуса изменит ветку повторов и отображение ошибки. Для каждого исхода задайте точную пару «статус — форма тела».
\nJSON Schema описывает документ: типы, обязательность, перечисления и ограничения, когда выбранный диалект и режим валидатора это поддерживают. Она не знает, имеет ли пользователь право видеть клиента, существует ли запись в базе и актуальна ли ревизия в момент обновления. Эти вопросы не нужно прятать в проверку формы: у них другие входы, причины отказа и тесты.
\nНиже — намеренно маленький валидатор на уже разобранном объекте. Он не исправляет ответ молча и не подставляет отсутствующую ревизию. Для production-кода понадобятся проверка фактического HTTP-ответа, единый формат ошибок и согласованный с командой способ обработки лишних полей.
\nfunction validateCustomerResponse(payload) {\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n return { ok: false, reason: 'body-must-be-object' };\n }\n\n if (typeof payload.id !== 'string' || payload.id.length === 0) {\n return { ok: false, reason: 'id-must-be-non-empty-string' };\n }\n\n if (!Number.isInteger(payload.revision) || payload.revision < 1) {\n return { ok: false, reason: 'revision-must-be-positive-integer' };\n }\n\n if (!['active', 'blocked'].includes(payload.state)) {\n return { ok: false, reason: 'state-is-outside-enum' };\n }\n\n return {\n ok: true,\n value: { id: payload.id, revision: payload.revision, state: payload.state },\n };\n}\n\nconst accepted = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'active',\n});\nconst wrongType = validateCustomerResponse({\n id: 'customer-17', revision: '4', state: 'active',\n});\nconst unknownState = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'deleted',\n});\n\nconsole.assert(accepted.ok === true);\nconsole.assert(wrongType.reason === 'revision-must-be-positive-integer');\nconsole.assert(unknownState.reason === 'state-is-outside-enum');\nconsole.log('contract checks passed');\nСкопируйте блок в файл contract-check.mjs и выполните node contract-check.mjs. Нулевой код завершения доказывает только три перечисленных свойства функции. Он не доказывает, что handler действительно вызывает эту функцию или что сериализатор не меняет данные после проверки. Именно поэтому проверку объекта дополняют тестом маршрута с настоящим статусом, заголовком и телом.
Тест потребителя должен запускать старый клиент против нового ответа. Успешная десериализация недостаточна: нужно пройти ветку, которая использует поле. Для state это означает проверить active, blocked и поведение на неизвестном значении, если сервер имеет право его прислать. Для удаляемого свойства — убедиться, что старый клиент не строит на его отсутствии неверное значение по умолчанию.
Полезный fixture хранит не только payload, но и ожидаемый результат: название экрана, решение о повторе, сформированный запрос или доменную ошибку. Тогда тест ловит смену смысла, которую структурная схема не видит. Версию потребителя выбирайте явно: тест «текущий клиент против текущего сервера» может оставаться зелёным после изменения, потому что оба обновились одновременно.
\nУчитывайте настройки декодера. В одном клиенте неизвестные свойства игнорируются, в другом запрещены; одни библиотеки превращают число в строку, другие требуют точного типа. Не называйте изменение совместимым по опыту одной реализации. Зафиксируйте режим парсера и повторите тест на минимальной поддерживаемой версии клиента.
\nКаждый шаг должен оставлять артефакт: diff схемы, fixture, результат теста или метрику. Фраза «клиенты не жаловались» не является доказательством: она не показывает покрытые версии, редкие ветки и неактивных потребителей.
\nСхема ответа не заменяет протокол ошибки. Если запрос сформирован правильно, но ресурс изменился между чтением и записью, клиенту нужен сигнал конфликта состояния, а не сообщение о неверном JSON. RFC 9110 описывает условные запросы и заголовок If-Match; его можно использовать как часть отдельного контракта конкурентного обновления, если сервер проверяет условие до изменения.
Авторизация, наличие ресурса и конкурентная версия имеют разные причины и обычно разные статусы. Нельзя выводить право доступа из того, что тело прошло схему. Нельзя считать revision: 4 доказательством, что обновление с ревизией 4 разрешено сейчас: это значение становится полезным только в договорённом протоколе проверки версии.
Ограничение применимости здесь принципиальное: показанный валидатор не проверяет OpenAPI-документ, правила JSON Schema, права, базу, транзакцию, сетевой таймаут или полноту списка потребителей. Он предотвращает конкретные ошибки формы в учебной границе. Для выпуска нужны интеграционный маршрут, старый клиент и наблюдаемое правило удаления.
\nИзменение ответа можно считать проверенным, когда команда показывает четыре независимых результата. Новая форма проходит схему и runtime-проверку на фактическом HTTP-ответе. Старый поддерживаемый клиент разбирает ответ и выполняет ожидаемую ветку. Отрицательные случаи имеют согласованные статусы и тела. Если diff несовместим, описаны версия или период совместимости и измеримое условие удаления.
\nЕсли зелёный результат есть только у unit-теста валидатора, это ещё не совместимость API. Если схема не описывает смысл поля, добавьте поведенческий consumer-тест. Если неизвестны потребители, не обещайте безопасное удаление: сначала соберите сигнал использования или выберите версионирование. Такой критерий делает решение проверяемым и оставляет видимой цену неизвестности.
\nПользователь открывает свой профиль, а затем меняет один идентификатор в URL и получает профиль другого пользователя. В журналах виден успешный ответ 200. В интерфейсе нет кнопки для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого. Исправление XSS в форме не закрывает этот путь. Экран может быть безопасным, а endpoint — нет.
\nТезис статьи простой: security-проверка готова только тогда, когда требование связано с конкретным субъектом, объектом и действием, а разрешение и отказ подтверждены на границе HTTP. Строка «авторизация проверена» ничего не доказывает. Доказательство содержит вход, ожидаемый ответ, фактический ответ и понятную причину расхождения.
\nПроверка владельца обычно начинается с положительного сценария. Пользователь u-1 запрашивает profile u-1 и получает 200. Этот тест показывает, что легитимный запрос работает. Он не показывает, что policy остановит profile u-2. Без отрицательной строки система может разрешать оба запроса.
\nПричина часто появляется на стыке слоёв. Handler получает id из URL. Middleware проверяет, что токен действителен. Репозиторий ищет запись по одному id. Если проверка владельца не входит в запрос или выполняется после чтения, соседний объект уже попал в ответ. Кэш способен усилить ошибку: ответ для u-1 сохранится под ключом profile:42 и станет доступен u-2.
Нужна объектная проверка. Субъект приходит из проверенного контекста сессии или токена. Объект приходит из маршрута и базы. Действие задаёт endpoint. Решение принимает код рядом с границей доступа, а не только компонент интерфейса.
\nФраза «пользователь видит только свой профиль» слишком короткая для теста. Разложите её на четыре поля: кто действует, что делает, над каким объектом и какой результат допустим. Для профиля минимальная политика выглядит так: user может read свой profile; user не может read чужой profile; неизвестная роль получает deny; admin может read audit, если это входит в контракт.
\n| Субъект | Действие | Объект | Ожидаемый результат |
|---|---|---|---|
| user u-1 | read | profile u-1 | allow, 200 |
| user u-1 | read | profile u-2 | deny, 403 или 404 |
| user u-1 | read | audit | deny, 403 или 404 |
| unknown | read | profile u-1 | deny, 401 или 403 |
Статус зависит от контракта. 403 сообщает, что запрос распознан, но запрещён. 404 иногда скрывает существование чужого объекта. Важно не выбрать «правильный» код вообще, а закрепить один вариант для конкретного endpoint и проверить его на внешней границе.
\nИдентификатор требования должен быть стабильным. Например, AUTH-PROFILE-01 связывает описание политики, тесты и запись изменения. Если требования ссылаются на OWASP ASVS, фиксируйте версию стандарта в ссылке. Идентификатор без версии может начать означать другой текст после обновления стандарта.
Политика должна сначала отказывать, а затем явно разрешать узкие комбинации. Для профиля нельзя проверять только роль. Два пользователя имеют одну роль, но разные объекты. Нельзя проверять только наличие токена. Валидная сессия не даёт доступ ко всем ресурсам.
\nfunction decide({ role, subjectId, action, resource }) {\n if (!role || !subjectId || !resource) {\n return { status: 'deny', reason: 'invalid-input' };\n }\n\n if (role === 'admin' && action === 'read' && resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' && resource.ownerId === subjectId) {\n return { status: 'allow', reason: 'object-ownership' };\n }\n\n return { status: 'deny', reason: 'default-deny' };\n}\nКод выше — учебный пример. Он не является готовым middleware и не проверяет токен, CSRF, tenant, срок сессии, rate limit, кэш или журналирование. Его задача — показать форму решения: функция принимает субъект, действие и объект; результат содержит статус и безопасную причину. Не передавайте в клиент внутренние сведения о policy. Причину используйте внутри теста и журнала с учётом правил о чувствительных данных.
\nВ реальном handler сначала извлеките субъект из уже проверенного контекста, затем загрузите объект с учётом tenant и вызовите policy. Не принимайте ownerId из тела запроса как доказательство владения. Клиент может изменить это поле. Источник владельца — доверенная запись на сервере.
Отказ не должен считаться исключением теста. Он должен быть ожидаемым результатом конкретного входа. Для каждой строки зафиксируйте имя, вход и результат. Так падение отвечает на вопрос: policy разрешила чужой объект, middleware потерял subject или handler вернул неверный статус.
\nimport assert from 'node:assert/strict';\n\nconst cases = [\n ['owner reads own profile',\n { role: 'user', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1' } },\n { status: 'allow', reason: 'object-ownership' }],\n ['owner cannot read foreign profile',\n { role: 'user', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-2' } },\n { status: 'deny', reason: 'default-deny' }],\n ['unknown role is denied',\n { role: 'guest', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1' } },\n { status: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n assert.deepEqual(decide(input), expected, name);\n console.log('PASS', name);\n}\nЭтот фрагмент также учебный. Его можно выполнить только после добавления функции decide из предыдущего фрагмента. Он не обращается к базе и не доказывает безопасность HTTP-слоя. Если такой unit-тест зелёный, это доказывает только выбранные ветки функции. Следующий тест должен вызвать реальный handler с двумя субъектами и двумя объектами.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не владельца объекта | Повторить запрос с u-1 к profile u-2 | Добавить object-level deny в handler или policy |
| User читает audit | Роль проверяют по наличию, а не по разрешению | Запросить ресурс с user и admin | Сделать список разрешённых role/action/resource явным |
| Неизвестная роль получает доступ | Ветка по умолчанию разрешает запрос | Подать роль guest или пустую роль | Вернуть deny до всех allow-веток |
| После исправления UI тест зелёный, API уязвим | Проверяли только скрытую кнопку | Вызвать endpoint напрямую без браузерного интерфейса | Перенести контроль на серверную границу и добавить HTTP-тест |
| Иногда виден чужой ответ | Ключ кэша не содержит tenant или subject | Повторить запрос после прогрева кэша разными пользователями | Разделить ключи или запретить кэширование приватного ответа |
Матрица не заменяет модель угроз. Она не проверяет CSRF для изменяющего запроса, подделку токена, SSRF, загрузку файлов, гонки, обход маршрутизации и настройку reverse proxy. Для multi-tenant системы добавьте tenant в субъект и объект. Для массовых операций проверьте каждый объект, а не только первый. Для файлов отдельная проверка должна учитывать тип содержимого, имя, хранение вне webroot и выдачу через авторизованный обработчик.
\nНе считайте зелёный unit-тест доказательством всей защиты. Отрицательный путь может сломаться между слоями: policy вернула deny, но adapter превратил его в 200; handler вернул 403, но кэш отдал старый 200; база загрузила запись другого tenant до проверки. Поэтому критерий должен проходить через реальный маршрут.
\nДля выбранного endpoint есть версия требования, матрица входов и автоматическая проверка. Запрос владельца получает закреплённый allow-ответ. Запрос к чужому объекту, неизвестная роль и недопустимое действие получают закреплённый deny-ответ. Прямой HTTP-вызов подтверждает это без UI. Повторная проверка после прогрева кэша не меняет результат между субъектами. В логе остаётся безопасный идентификатор проверки, но не секрет и не лишние персональные данные.
\nЕсли хотя бы одна строка не имеет фактического результата, проверка не закончена. Если результат зависит от случайного состояния, сначала стабилизируйте окружение и зафиксируйте границу. Готовность — это не отсутствие замечаний в чек-листе. Это воспроизводимый отказ на запрещённом входе и воспроизводимое разрешение на допустимом.
\nПользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.
\nРазберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.
\nАутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает id из URL, а затем делает поиск только по этому id, он может вернуть запись, которая субъекту не принадлежит.
Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать GET /profiles/:id, но не вправе выбрать любой :id. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.
Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.
\nФраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный user читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.
| Субъект | Действие | Объект | Ожидаемый ответ | Доверенный источник |
|---|---|---|---|---|
| u-1, t-1 | read | profile u-1, t-1 | allow, 200 | session + database |
| u-1, t-1 | read | profile u-2, t-1 | deny, 403 | session + database |
| u-1, t-1 | read | profile u-3, t-2 | deny, 403 | session + database |
| нет valid credentials | read | profile u-1, t-1 | deny, 401 | auth layer |
| u-1, t-1 | delete | profile u-1, t-1 | deny, 403 | route policy |
Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует WWW-Authenticate; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.
Нарисуйте путь данных до того, как писать условие. subjectId, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.
Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. ownerId из JSON описывает желание клиента, но не доказывает владение.
function authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (\n role === 'user' &&\n action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId\n ) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}\nЭто учебная policy-функция, а не готовое middleware. Она не проверяет подпись токена, срок сессии, CSRF, rate limit, кэш или журналирование. Её полезная граница уже видна: решение зависит от роли, действия, tenant и server-side владельца; неизвестная комбинация не проходит через случайную ветку allow.
\nHappy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как authorization-policy.test.js и выполните командой node authorization-policy.test.js. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.
const assert = require('node:assert/strict');\n\nfunction authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}\n\nconst cases = [\n ['owner reads own profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'allow', reason: 'same-tenant-owner' }],\n ['owner cannot read foreign profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-2', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['cross-tenant profile is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-2' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unknown role is denied',\n { role: 'guest', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unsupported action is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'delete',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n assert.deepEqual(authorize(input), expected, name);\n console.log('PASS', name);\n}\nЭтот тест теперь воспроизводим как unit-проверка, но не маскирует границу. Следующий слой должен вызвать реальный handler и передать ему две тестовые identities, два tenant-а и два объекта. Для read-only endpoint-а безопасный шаблон запроса выглядит так:
\ncurl -sS -i \\\n -H 'Authorization: Bearer <test-token-u-1>' \\\n 'https://test.example.test/api/profiles/u-2'\nНа тестовом окружении ожидайте выбранный контракт, здесь — 403 и отсутствие данных u-2 в теле. Второй запрос к u-1 должен дать 200, запрос без credentials — 401 с WWW-Authenticate. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.
Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не объект | u-1 запрашивает профиль u-2 напрямую | Добавить object-level deny и HTTP-тест |
| u-1 видит объект tenant t-2 | Tenant ограничили после общего чтения | Повторить запрос двумя tenant-ами | Включить tenant в выборку и policy |
| Policy вернула deny, HTTP дал 200 | Adapter или handler потерял решение | Сравнить policy result, статус и body | Сделать mapping явным и тестируемым |
| Чужой ответ появляется после прогрева кэша | Ключ не содержит permission context | Поменять субъекта после первого запроса | Разделить ключ или отключить общий cache |
| 403 раскрывает существование записи | Публичный ответ повторяет внутреннюю причину | Сравнить чужой и отсутствующий объект | Выбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным |
| UI-тест зелёный, endpoint уязвим | Проверяли только видимость кнопки | Вызвать маршрут без браузера | Оставить HTTP-проверку в CI |
Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.
\nЭта схема не проверяет подпись и срок жизни токена, MFA, CSRF, права на отдельные поля, загрузку файлов, SSRF, rate limit, гонки, репликацию базы, reverse proxy и корректность всех альтернативных маршрутов. Для массового запроса проверяйте каждый объект, а не только первый. Для администратора описывайте отдельные grants: роль сама по себе не означает право читать всё.
\nГотовность одного endpoint-а можно сформулировать строго: есть версия требования, доверенный источник subject/tenant/owner/action, серверная object-level проверка, unit-отказ и успешный HTTP-тест владельца. Прямой запрос к чужому и cross-tenant объекту возвращает закреплённый deny-ответ; запрос без credentials проходит auth-контракт; после кэша результат не меняется между субъектами. Это доказательство выбранной границы, а не сертификат безопасности приложения.
\n| Источник | Что подтверждает | Ограничение применимости |
|---|---|---|
| OWASP API1:2023 Broken Object Level Authorization | Изменение object ID может обойти контроль; endpoint, работающий с объектом по client input, должен проверять право на этот объект; нужны тесты authorization. | Это категория риска API и рекомендации, а не проверка конкретного приложения и не гарантия покрытия. |
| OWASP API5:2023 Broken Function Level Authorization | Function-level доступ нужно явно разрешать ролям, а неизвестные комбинации отклонять по умолчанию; это отдельная проблема от object-level доступа. | Материал не выбирает роли, tenant-модель или публичные HTTP-коды конкретного продукта. |
| OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References | Нужно картировать прямые ссылки на объекты, менять параметр и сравнивать доступ как минимум для двух пользователей с разными объектами. | Latest-версия руководства может обновляться; это методика тестирования, а не compliance standard и не разрешение тестировать чужие системы. |
| IETF RFC 9110, раздел 15.5 | Смысл 401, 403 и 404: credentials, отказ в выполнении и допустимое сокрытие существования ресурса; для 401 требуется WWW-Authenticate. | RFC описывает семантику HTTP, но не задаёт application policy, модель угроз или выбор между 403 и 404 для конкретного сервиса. |
Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным. Сервер проверяет его наличие и отдаёт найденную запись. Симптом выглядит как обычный доступ к странице, но это горизонтальная эскалация прав.
Цена ошибки — утечка персональных данных, изменение чужих объектов и потеря границы между tenant-ами. Чем больше endpoint-ов строится по схеме «взяли id из URL, нашли запись, вернули ответ», тем больше таких точек появляется. Скрытая кнопка в интерфейсе не помогает: запрос можно повторить вручную.
Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Между вопросами стоят роль, область доступа и принадлежность записи. Если сервер не проверяет их отдельно, валидная сессия превращается в пропуск к любому известному идентификатору.
Надёжное решение принимает четыре значения: субъект, действие, объект и контекст политики. Субъект приходит из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода. Объект загружается сервером. Владелец, tenant и чувствительность объекта берутся из доверенных данных, а не из тела запроса. Неизвестная комбинация получает deny.
Сначала middleware или слой сессии устанавливает subjectId и роль. Затем handler разбирает путь и получает идентификатор ресурса. Репозиторий возвращает объект вместе с его владельцем и tenant-ом. Policy layer сравнивает эти поля с субъектом и разрешает только явно описанные действия. UI может скрыть недоступную кнопку, но решение всё равно принимает сервер.
Правило владельца нельзя заменить проверкой роли. Два пользователя могут иметь одну роль user, но видеть разные профили. Роль говорит о классе полномочий. Владелец говорит о конкретном объекте. Для администратора нужен отдельный allow-список: «читать аудит» не равно «читать любую персональную запись», а «администратор» не должно означать «разрешено всё».
Нельзя принять ownerId из JSON и использовать его как доказательство владения. Клиент сообщает, какой объект он хочет выбрать. Сервер сам читает владельца из базы или доменного сервиса. Для multi-tenant системы запрос к хранилищу должен сразу включать tenant boundary. Если сначала получить запись без ограничения области, последующая проверка уже может оказаться слишком поздней.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Замена id в URL показывает чужой профиль | Проверили сессию, но не владельца объекта | Отправить запрос для своего и соседнего идентификатора | Сравнивать subjectId с владельцем записи; чужой объект отклонять |
| Пользователь читает audit endpoint | Роль проверяют слишком широко | Вызвать endpoint с ролью user напрямую, без UI | Описать ресурс и действие в отдельном allow-правиле |
| Неизвестная роль получает 200 | Ветка по умолчанию пропускает запрос | Удалить или подменить claim и повторить вызов | Сделать результатом по умолчанию deny |
| Чужой ответ появляется после кэширования | Ключ кэша не содержит субъекта или области | Повторить запрос разными субъектами и сравнить тело | Разделить кэш по permission context либо не кэшировать ответ |
| 403 раскрывает существование записи | Внешний ответ повторяет внутреннюю причину | Сравнить ответ для отсутствующего и чужого объекта | Выбрать 404 или 403 по модели угроз, причину логировать безопасно |
Ниже — маленький пример на Node.js. Профили хранятся в Map, а субъект и роль передаются заголовками только для учебного сценария. Реальная система должна получать их из проверенной сессии, JWT или другого принятого механизма. Пример не подключается к production и не доказывает безопасность конкретного приложения.
const profiles = new Map([['u-1', { owner: 'u-1', tenant: 't-1' }], ['u-2', { owner: 'u-2', tenant: 't-1' }]]); function decide({ role, subjectId, tenant, resource, action }) { if (role === 'admin' && resource.kind === 'audit' && action === 'read') return { status: 200, reason: 'admin-audit' }; if (role === 'user' && resource.kind === 'profile' && action === 'read' && resource.tenant === tenant && resource.owner === subjectId) return { status: 200, reason: 'owner' }; return { status: 403, reason: 'default-deny' }; } const id = new URL(request.url, 'http://local').searchParams.get('id'); const profile = profiles.get(id); const resource = profile && { kind: 'profile', ...profile }; const result = resource ? decide({ role, subjectId, tenant, resource, action: 'read' }) : { status: 404, reason: 'not-found' };Условие для профиля проверяет роль, действие, tenant и владельца. Запрос от u-1 к u-1 получает 200. Тот же субъект к u-2 получает 403. Если tenant отличается, результат также deny. Идентификатор из URL только выбирает запись; он не назначает ей владельца.
Проверка существования объекта требует отдельного решения. В примере отсутствующий профиль даёт 404, а найденный чужой — 403. В некоторых системах оба случая наружу превращают в 404, чтобы не раскрывать наличие записи. Это не универсальное правило. Выбор зависит от модели угроз. Внутри сохраняйте короткий класс причины и не пишите в журнал полный токен или секретные параметры.
Разрешённый запрос показывает, что легитимный сценарий работает. Он не показывает, что граница закрыта. Минимальный набор должен включать чужой объект, запрещённое действие, неизвестную роль, другой tenant, отсутствующий ресурс и повторный вызов через прямой HTTP-клиент. Для mutation добавьте проверку метода и защиту от повторной операции. Для чтения проверьте кэш и сериализацию ответа.
Проверяйте policy без интерфейса. Если тест кликает только по видимой кнопке, он не проверяет handler. Отправьте запрос с изменённым id, вручную задайте роль и удалите обязательный claim. Эти входы учебные и не должны содержать реальные идентификаторы или секреты. Их смысл — показать отрицательную ветку, а не воспроизвести доступ к настоящим данным.
deny результатом для неизвестной комбинации.Учебный код использует заголовки вместо настоящей аутентификации и Map вместо базы. Он не проверяет срок жизни токена, подпись JWT, CSRF, race condition, права на поля, согласованность реплик и поведение прокси. Он также не решает, как кэшировать персональный ответ. Эти вопросы требуют отдельных контрактов и тестов.
Проверка готова для одного endpoint-а, если видны источник subjectId, правило области, серверный способ получения владельца, явное действие и default deny. Интеграционный тест должен показать 200 для разрешённого объекта, отказ для чужого объекта и отказ для неизвестной роли через реальный HTTP-маршрут. Если проходит только unit-тест policy или только проверка UI, работа не готова: граница между запросом, хранилищем и ответом ещё не доказана.
Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным, поэтому проверка логина проходит. Ошибка возникает дальше: сервер не проверяет, имеет ли этот субъект право читать выбранный объект. Это горизонтальная эскалация прав.
Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, curl или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.
Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль user ещё не означают право читать любой ресурс с той же ролью.
Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: GET /profile — чтение, PATCH /profile — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть tenantId, состояние записи, принадлежность команде или требуемый уровень чувствительности.
Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится deny. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.
Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный subjectId, роль и, если применимо, tenantId. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.
Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре id + tenantId, а не сначала получать любой объект по одному id. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.
Клиентский ownerId не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.
Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Замена id в URL показывает чужой профиль | Сессию проверили, владельца объекта — нет | Запросить свой и соседний идентификатор одной сессией | Сверять subjectId с владельцем и областью объекта |
Роль user читает audit endpoint | Проверка роли не связана с ресурсом и действием | Вызвать маршрут напрямую, без интерфейса | Добавить отдельное allow-правило для audit:read |
| Неизвестная роль получает 200 | Ветка по умолчанию пропускает запрос | Удалить claim и повторить вызов | Вернуть отказ для любой неописанной комбинации |
| Чужой ответ появляется после кэширования | Ключ кэша не учитывает область или permission context | Сравнить ответы для двух субъектов | Разделить кэш или отключить его для персонального ответа |
| Ответ раскрывает наличие чужой записи | Внешний статус и текст выбраны без модели угроз | Сравнить отсутствующий и запрещённый объект | Выбрать 403 или маскирующий 404 и не раскрывать причину |
Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: Map заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.
const profiles = new Map([\n ['u-1', { ownerId: 'u-1', tenantId: 't-1', displayName: 'Ada' }],\n ['u-2', { ownerId: 'u-2', tenantId: 't-1', displayName: 'Linus' }],\n ['u-3', { ownerId: 'u-3', tenantId: 't-2', displayName: 'Grace' }],\n]);\n\nfunction authorizeProfileRead(session, requestedId) {\n const profile = profiles.get(requestedId);\n if (!profile) return { status: 404, reason: 'not-found' };\n if (session.role !== 'user') return { status: 403, reason: 'role-denied' };\n if (profile.tenantId !== session.tenantId) {\n return { status: 403, reason: 'tenant-denied' };\n }\n if (profile.ownerId !== session.subjectId) {\n return { status: 403, reason: 'owner-denied' };\n }\n return {\n status: 200,\n body: { id: requestedId, displayName: profile.displayName },\n };\n}\n\nconst own = authorizeProfileRead(\n { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-1');\nconst foreignObject = authorizeProfileRead(\n { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-2');\nconst otherTenant = authorizeProfileRead(\n { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-3');\n\nconsole.log(own.status, foreignObject.status, otherTenant.status); // 200 403 403\nВ примере URL-идентификатор передаётся только в requestedId. Субъект, роль и tenant приходят из session, а владелец читается из найденного профиля. Поэтому запрос от u-1 к u-2 не становится разрешённым после замены параметра. Изменение кода на реальный handler должно сохранить ту же последовательность и добавить проверку HTTP-метода, схемы входа и сериализации.
Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.
\nУспешное чтение собственного профиля доказывает только один allow-сценарий. Оно не показывает, что граница закрыта. Минимальный набор проверок должен содержать чужой объект в том же tenant-е, объект другого tenant-а, запрещённое действие, неизвестную роль, отсутствующий объект и запрос без обязательных учётных данных. Для mutation дополнительно проверяйте, что состояние не изменилось после отказа.
\nОтправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым id. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.
Причину отказа можно сохранить в безопасном журнале коротким кодом вроде owner-denied. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.
Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.
\nФильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».
\nEndpoint можно считать проверенным только для заявленного контракта, если видны источник субъекта, правило области, серверный способ получения владельца, действие, внешний статус и доказательство отказа. Интеграционный тест должен дать 200 своему объекту, отказать чужому объекту и неизвестной роли через реальный маршрут. Если зелёным остаётся только unit-тест policy или только проверка UI, связь между запросом, хранилищем и ответом ещё не доказана.
\nСервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.
Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.
\nURL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.
Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| «Доверенный» URL обращается к loopback | Проверяли строковый префикс или часть до @ | Распарсить URL и вывести только hostname | Запретить credentials и сравнивать фактический host |
| Проходит похожий домен | Использовали endsWith без границы имени | Проверить evil-example.test и sub.example.test | Разрешать точное имя или явный суффикс .example.test |
| Запрос уходит на другой адрес после 302 | Клиент автоматически следует redirect | Перехватить заголовок Location | Запретить redirect или повторить политику для каждого нового URL |
| Имя разрешено, IP закрытый | Проверен hostname, но не результат DNS | Проверить A и AAAA и диапазоны адресов | Сверить адреса с политикой и контролировать egress |
| Разрешённый ответ занимает память | Есть allowlist, но нет лимита тела | Проверить Content-Length и поток чтения | Остановить чтение после заданного размера и ограничить timeout |
Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.
Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.
\nПорт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.
Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.
function validateRemoteUrl(value, allowedHosts) {\n let url;\n try {\n url = new URL(value);\n } catch {\n return { allowed: false, reason: 'invalid-url' };\n }\n\n if (url.protocol !== 'https:') {\n return { allowed: false, reason: 'scheme' };\n }\n if (url.username || url.password) {\n return { allowed: false, reason: 'credentials' };\n }\n if (url.port && url.port !== '443') {\n return { allowed: false, reason: 'port' };\n }\n if (!allowedHosts.includes(url.hostname)) {\n return { allowed: false, reason: 'host' };\n }\n\n return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n 'https://cdn.example.test/file.jpg',\n ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n 'https://cdn.example.test@127.0.0.1/file.jpg',\n ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }\nПервый пример проходит allowlist. Во втором функция останавливается на credentials. Адрес https://127.0.0.1/file.jpg остановится на hostname. Это отрицательный путь: приложение не должно сначала выполнить запрос, а потом решить, был ли адрес допустим. Не включайте полный входной URL в лог отказа. В нём могут быть пароль, token или query с персональными данными.
Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.
Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.
Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA там, где это важно для модели угроз.
\nВ чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.
\nDNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.
Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.
\nНе принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.
\n@, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.
\nEndpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.
\nСервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.
Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.
\nURL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.
Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| «Доверенный» URL обращается к loopback | Проверяли строковый префикс или часть до @ | Распарсить URL и вывести только hostname | Запретить credentials и сравнивать фактический host |
| Проходит похожий домен | Использовали endsWith без границы имени | Проверить evil-example.test и sub.example.test | Разрешать точное имя или явный суффикс .example.test |
| Запрос уходит на другой адрес после 302 | Клиент автоматически следует redirect | Перехватить заголовок Location | Запретить redirect или повторить политику для каждого нового URL |
| Имя разрешено, IP закрытый | Проверен hostname, но не результат DNS | Проверить A и AAAA и диапазоны адресов | Сверить адреса с политикой и контролировать egress |
| Разрешённый ответ занимает память | Есть allowlist, но нет лимита тела | Проверить Content-Length и поток чтения | Остановить чтение после заданного размера и ограничить timeout |
Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.
Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.
\nПорт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.
Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.
function validateRemoteUrl(value, allowedHosts) {\n let url;\n try {\n url = new URL(value);\n } catch {\n return { allowed: false, reason: 'invalid-url' };\n }\n\n if (url.protocol !== 'https:') {\n return { allowed: false, reason: 'scheme' };\n }\n if (url.username || url.password) {\n return { allowed: false, reason: 'credentials' };\n }\n if (url.port && url.port !== '443') {\n return { allowed: false, reason: 'port' };\n }\n if (!allowedHosts.includes(url.hostname)) {\n return { allowed: false, reason: 'host' };\n }\n\n return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n 'https://cdn.example.test/file.jpg',\n ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n 'https://cdn.example.test@127.0.0.1/file.jpg',\n ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }\nconst checks = [\n ['https://cdn.example.test/file.jpg', true, 'allowlist'],\n ['https://cdn.example.test@127.0.0.1/file.jpg', false, 'credentials'],\n ['https://127.0.0.1/file.jpg', false, 'host'],\n];\n\nfor (const [input, allowed, reason] of checks) {\n const result = validateRemoteUrl(input, ['cdn.example.test']);\n if (result.allowed !== allowed || result.reason !== reason) {\n throw new Error('Unexpected policy result for ' + input);\n }\n}\n\nconsole.log('URL policy checks: PASS');\nТестовый прогон проверяет три решения без сети: разрешённый host, credentials перед loopback и прямой loopback. Он не заменяет отдельные тесты DNS, IP, redirect и сетевого egress.
\nПервый пример проходит allowlist. Во втором функция останавливается на credentials. Адрес https://127.0.0.1/file.jpg остановится на hostname. Это отрицательный путь: приложение не должно сначала выполнить запрос, а потом решить, был ли адрес допустим. Не включайте полный входной URL в лог отказа. В нём могут быть пароль, token или query с персональными данными.
Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.
Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.
Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA, если модель угроз требует контроля каждого адреса.
\nВ чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.
\nDNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.
Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.
\nНе принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.
\n@, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Он также не ограничивает путь запроса, размер тела или число редиректов. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.
\nEndpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.
\nСервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.
\nЦена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.
\nТезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принимать решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.
\nОдна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать. Если записать только финальное «503», расследование потеряет порядок событий.
Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded.
Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, fail и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.
| Поле | Пример | Зачем |
|---|---|---|
operationId | op-42 | Связать попытки одной операции |
attempt | 2 | Увидеть порядок и число вызовов |
method | GET | Проверить семантику повтора |
status или errorClass | 503, timeout | Отделить ответ сервера от исключения |
remainingMs | 180 | Понять, сколько времени оставалось |
decision | retry | Зафиксировать решение клиента |
HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации. Серверный обработчик и его побочные эффекты остаются частью проверки.
\nGET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и не игнорирует Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия и сохранённый результат.
Учебный пример ниже намеренно консервативен. Он повторяет только GET со статусом 503. Массив ответов заменяет сеть, поэтому код не доказывает поведение конкретной библиотеки и не описывает production-систему.
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n const events = [];\n\n for (let i = 0; i < Math.min(maxAttempts, responses.length); i += 1) {\n const result = responses[i];\n const remainingMs = Math.max(0, deadlineMs - i * 120);\n const retryable = method === 'GET' && result.status === 503;\n const decision = remainingMs === 0 ? 'fail' : retryable ? 'retry' : 'return';\n\n events.push({\n operationId,\n attempt: i + 1,\n method,\n status: result.status,\n remainingMs,\n decision\n });\n\n if (decision !== 'retry') {\n return { result: decision === 'fail' ? { status: 'deadline_exceeded' } : result, events };\n }\n }\n\n return { result: { status: 'deadline_exceeded' }, events };\n}\n\nrunBoundedRetries({\n operationId: 'op-42',\n method: 'GET',\n responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});\nВ этом учебном наборе клиент создаёт три события и возвращает 200. Если заменить метод на POST, первый 503 получит решение return. Такой результат не означает, что любой POST надо немедленно завершать. Он показывает отрицательный путь: без доказанной идемпотентности повтор запрещён.
В настоящем клиенте есть ещё одна проверка. Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Резкий рост запросов после 503 | Нет общего deadline или backoff | Сравнить attempt и remainingMs | Ограничить бюджет, добавить задержку и jitter |
| Один пользователь получил две записи | POST повторили после timeout | Сопоставить operationId на сервере | Остановить retry, ввести ключ и запрос состояния |
| В логах только «failed» | Причина и decision слиты | Найти поля status/errorClass и decision | Сделать перечисление причин и действий |
| Клиент ждёт дольше SLA | Timeout задан на попытку, не на операцию | Проверить остаток времени перед каждым вызовом | Передавать общий deadline вниз по стеку |
| После 429 нагрузка не падает | Клиент игнорирует ограничение сервера | Проверить Retry-After и частоту попыток | Снизить темп и завершать попытку по политике лимита |
| Нельзя связать клиентский и серверный след | Идентификатор меняется при retry | Сопоставить operationId и requestId | Сохранить идентификатор операции, а запросу дать номер попытки |
Рассмотрим последовательность для чтения. Первая попытка получила 503 при остатке 380 миллисекунд. Клиент записал decision=retry, подождал ограниченный интервал и повторил запрос. Вторая попытка снова получила 503. Осталось 120 миллисекунд, поэтому третья попытка допустима только после оценки её минимального времени выполнения. Если бюджет мал, клиент завершает операцию с deadline_exceeded, даже если в массиве есть следующий ответ.
Теперь рассмотрим запись. Сервер мог принять запрос, но соединение оборвалось до ответа. Клиент записал errorClass=timeout, decision=check_state и сохранил operation key. Он не создаёт новую запись. Это медленнее, чем слепой retry, но цена неизвестного результата ниже цены дублирования побочного эффекта.
Для логов достаточно безопасного endpoint без query-секретов, метода, статуса, класса ошибки, номера попытки, оставшегося времени и решения. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины. Поле наблюдаемости не должно становиться новым каналом утечки.
\nЛокальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.
\nНи RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.
\nИзменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после двух временных отказов видны попытки 1 и 2 с decision=retry, а затем успешный возврат либо честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.
Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.
\nСервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.
\nЦена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.
\nТезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принять решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.
\nОдна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать: requestId помогает найти один сетевой вызов, а operationId связывает весь пользовательский сценарий. Если записать только финальное «503», расследование потеряет порядок событий.
Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded; когда достигнут лимит попыток, это другое состояние.
Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, check_state, attempts_exhausted и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.
| Поле | Пример | Зачем |
|---|---|---|
operationId | op-42 | Связать попытки одной операции |
requestId | req-02 | Найти одну сетевую попытку |
attempt | 2 | Увидеть порядок и число вызовов |
method | GET | Проверить семантику повтора |
status или errorClass | 503, timeout | Отделить ответ сервера от исключения |
remainingMs | 180 | Понять, сколько времени оставалось |
decision | retry | Зафиксировать решение клиента |
HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации: серверный обработчик и его побочные эффекты остаются частью проверки.
\nGET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и учитывает Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия, проверка параметров и сохранённый результат.
Статус сам по себе тоже не даёт разрешения на retry. 503 обычно означает временную недоступность, но ответ посредника мог появиться после того, как upstream уже применил запись. 429 требует учесть ограничение сервера, а ошибка DNS, отмена пользователем и ошибка валидации не должны попадать в один список с временным отказом.
Учебный пример ниже намеренно консервативен. Он не обращается в сеть, а получает заранее заданный массив ответов. Это позволяет воспроизвести решение клиента и отдельно увидеть разницу между последней попыткой и исчерпанным deadline. Пример повторяет только GET со статусом 503; он не доказывает поведение конкретной HTTP-библиотеки.
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n const events = [];\n const attemptLimit = Math.min(maxAttempts, responses.length);\n\n for (const [index, result] of responses.slice(0, attemptLimit).entries()) {\n const attempt = index + 1;\n const remainingMs = Math.max(0, deadlineMs - index * 120);\n const isLastAttempt = attempt === attemptLimit;\n const canRetry = method === 'GET' && result.status === 503 && remainingMs > 0 && !isLastAttempt;\n const decision = remainingMs === 0\n ? 'deadline_exceeded'\n : canRetry\n ? 'retry'\n : method === 'POST' && result.status === 'timeout'\n ? 'check_state'\n : isLastAttempt && result.status === 503\n ? 'attempts_exhausted'\n : 'return';\n\n events.push({ operationId, attempt, method, status: result.status, remainingMs, decision });\n\n if (decision !== 'retry') {\n const finalResult = decision === 'deadline_exceeded'\n ? { status: 'deadline_exceeded' }\n : decision === 'attempts_exhausted'\n ? { status: 'attempts_exhausted' }\n : decision === 'check_state'\n ? { status: 'unknown_result' }\n : result;\n return { result: finalResult, events };\n }\n }\n\n return { result: { status: 'attempts_exhausted' }, events };\n}\n\nconst example = runBoundedRetries({\n operationId: 'op-42',\n method: 'GET',\n responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});\n\nconsole.assert(example.result.status === 200);\nconsole.assert(example.events.map((event) => event.decision).join(',') === 'retry,retry,return');\nВ этом наборе клиент создаёт три события и возвращает 200. Последняя попытка не получает решение retry, потому что дальше идти нельзя. Если заменить третий ответ на 503, результатом станет attempts_exhausted, а не ошибочно названный deadline_exceeded. Если заменить метод на POST и ответ на timeout, клиент перейдёт в unknown_result и не создаст второй вызов.
Проверки console.assert фиксируют итог и порядок решений. Они не заменяют тест реального клиента: в production нужно измерять монотонное время, обрабатывать сетевые исключения, учитывать Retry-After, добавлять backoff с jitter и передавать отдельный requestId для каждой попытки.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Резкий рост запросов после 503 | Нет общего deadline или backoff | Сравнить attempt и remainingMs | Ограничить бюджет, добавить задержку и jitter |
| Один пользователь получил две записи | POST повторили после timeout | Сопоставить operationId на сервере | Остановить retry, ввести ключ и запрос состояния |
| В логах только «failed» | Причина и decision слиты | Найти status/errorClass и decision | Сделать перечисление причин и действий |
| Клиент ждёт дольше SLA | Timeout задан на попытку, не на операцию | Проверить остаток времени перед вызовом | Передавать общий deadline вниз по стеку |
| После 429 нагрузка не падает | Клиент игнорирует ограничение сервера | Проверить Retry-After и частоту попыток | Снизить темп и завершать попытку по политике лимита |
| Нельзя связать клиентский и серверный след | Идентификатор меняется при retry | Сопоставить operationId и requestId | Сохранить идентификатор операции, запросу дать новый номер |
Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.
Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Повтор с тем же ключом и теми же параметрами должен вернуть сохранённый результат по правилам API. Тот же ключ с другим телом должен завершаться конфликтом до нового побочного эффекта, иначе старый результат можно ошибочно выдать за результат новой команды.
\nЛоги помогают расследованию, но не делают повтор безопасным. Записывайте метод, endpoint без секретных параметров, обезличенный ключ операции, requestId, номер попытки, статус, класс ошибки, остаток времени и решение. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины.
\nЛокальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.
\nНи RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.
\nУчебная функция не моделирует два процесса, атомарность базы, частичную запись, истечение TTL ключа или повтор после восстановления. Поэтому она годится для проверки ветвления и названий состояний, но не для обещаний о доступности или времени ответа. Производственные числа получают из наблюдений конкретной системы.
\nИзменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после временных отказов видны попытки с decision=retry, а затем успешный возврат, attempts_exhausted или честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.
Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.
\n