diff --git a/editorial/agent-rewrites/011.json b/editorial/agent-rewrites/011.json index d8f74c6..7422f08 100644 --- a/editorial/agent-rewrites/011.json +++ b/editorial/agent-rewrites/011.json @@ -2,6 +2,6 @@ "index": 11, "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; сумма платежа должна соответствовать разрешённому типу операции; поле 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. Применяйте его как опору для статусов, а не как замену доменному контракту.Клиент отправляет команду на изменение заказа. Обязательные поля на месте, типы верны, JSON Schema пропускает документ. Через несколько миллисекунд сервис отвечает отказом: заказ уже оплачен, ревизия устарела или роль пользователя не даёт права на изменение. Если все ветки записать как validation failed, клиент может повторить необратимое действие, оператор увидит лишнюю операцию, а разработчик начнёт искать причину то в схеме, то в базе. Цена ошибки — потерянное время и риск изменить ресурс второй раз.
Здесь слово «valid» отвечает только на вопрос о документе. Для операции нужны ещё три ответа: согласованы ли поля между собой, имеет ли актор право на действие и можно ли применить его к текущему состоянию. Практическое правило — назначать проверку по источнику контекста. Вход достаточно проверить схемой, связь полей — чистой функцией, права — политикой авторизации, а ревизию и переход состояния — внутри защищённой операции.
\nФорма. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из объявленных значений. Такая проверка читает только вход. Для одинакового документа она должна давать одинаковый результат и не обращаться к базе, часам или сети.
Инвариант. Отдельные поля могут быть корректны, но противоречить друг другу. Диапазон требует, чтобы from не был позже to; команда отмены не должна одновременно содержать действие «оплатить». Если правило зависит только от входного объекта, его удобно выразить чистой функцией и покрыть положительным и отрицательным тестом.
Право. Схема не знает, кто отправил запрос, к какому ресурсу относится роль и не отозвано ли разрешение. Проверка canEditOrder(actor, orderId) использует контекст субъекта и политики. Её результат нельзя получить из добавленного в JSON поля role: такое поле не доказывает подлинность полномочий.
Состояние. Документ может быть безупречным, но заказ уже перешёл в paid, склад изменился или ревизия стала другой. Сервис должен проверить это рядом с записью — в транзакции или условном обновлении. Предварительное чтение без защиты от гонки оставляет окно, в котором другой запрос успеет изменить ресурс.
| Слой | Нужный контекст | Пример отказа | Ответ сервиса |
|---|---|---|---|
| Форма | Только документ | limit — строка | 400, исправить вход |
| Инвариант | Несколько полей документа | from позже to | 400 или 422 по контракту |
| Право | Актор и политика ресурса | Роль не может отменить заказ | 403, не повторять без изменения полномочий |
| Состояние | Актуальный ресурс и ревизия | Заказ уже оплачен | 409 или 412, перечитать либо разрешить конфликт |
Коды в последнем столбце — часть конкретного API, а не автоматический вывод схемы. Важно, чтобы клиент различал ошибку входа, запрет и конфликт состояния: у них разные исправления и разные правила повторной попытки.
\nJSON Schema описывает экземпляр документа: типы, обязательность, границы чисел, перечисления, структуру объектов и политику дополнительных ключей. Ключевой нюанс — наличие имени в properties не делает поле обязательным; для этого нужен required. Аналогично, additionalProperties: false ловит опечатки, но превращает добавление нового поля в изменение контракта. Решение о закрытой или расширяемой форме нужно принять для конкретного endpoint и закрепить тестом.
У ключевого слова format есть отдельная граница. В Draft 2020-12 оно относится к vocabulary форматов-аннотаций; assertion-проверка формата включается отдельным vocabulary и поддержкой валидатора. Поэтому запись format: 'date' сама по себе не даёт права утверждать, что любой runtime отвергнёт несуществующую календарную дату. Если отказ обязателен, настройте валидатор явно или добавьте проверку, которую можно вызвать и протестировать.
OpenAPI 3.1 использует Schema Object как расширение JSON Schema и помогает описать входы и выходы HTTP-интерфейса. Это документация контракта, а не доказательство того, что handler действительно проверяет те же поля. Схема, сгенерированные типы и runtime-валидатор должны быть связаны проверкой сборки или интеграционным тестом; один из них не заменяет остальные.
\nНиже — учебный фрагмент без HTTP-сервера и базы. В нём отдельно видны форма и межполевая проверка. Поле format оставлено намеренно: рядом есть явная проверка календарной даты, чтобы пример не зависел от скрытой настройки конкретной библиотеки.
const filterSchema = {\n $schema: 'https://json-schema.org/draft/2020-12/schema',\n type: 'object',\n required: ['limit', 'from', 'to'],\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 isCalendarDate(value) {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n if (!match) return false;\n\n const date = new Date(`${value}T00:00:00Z`);\n return date.getUTCFullYear() === Number(match[1])\n && date.getUTCMonth() + 1 === Number(match[2])\n && date.getUTCDate() === Number(match[3]);\n}\n\nfunction validateRange(input) {\n if (!isCalendarDate(input.from) || !isCalendarDate(input.to)) {\n return { ok: false, reason: 'invalid-date' };\n }\n if (input.from > input.to) {\n return { ok: false, reason: 'from-after-to' };\n }\n return { ok: true };\n}\n\nconst good = { limit: 25, from: '2027-09-10', to: '2027-09-12' };\nconst badRange = { ...good, from: '2027-09-13' };\nconsole.log(validateRange(badRange));\n// { ok: false, reason: 'from-after-to' }\nСамостоятельно запустить можно последнюю функцию в Node.js или перенести её в тест выбранного языка. Вызов валидатора схемы здесь не привязан к конкретной библиотеке: её API различается. Задача примера — определить форму документа, затем проверить связь дат. Для настоящего endpoint положительный документ и случаи с неправильным типом, лишним ключом, несуществующей датой и обратным диапазоном должны попасть в один набор тестов.
\nДля операции добавляется контекст актора и ресурса. Пример использует getForUpdate как условное имя блокирующего чтения. В конкретной базе нужно доказать, что блокировка, уровень изоляции и обработка ошибок действительно защищают запись.
async function updateOrder(command, actor) {\n const shape = validateCommandShape(command);\n if (!shape.ok) return httpError(400, 'invalid-shape', shape.errors);\n\n const invariant = validateOrderCommand(command);\n if (!invariant.ok) return httpError(422, 'invalid-command', invariant.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) return httpError(404, 'not-found');\n\n if (order.state === 'paid' || order.revision !== command.expectedRevision) {\n return httpError(409, 'state-conflict');\n }\n\n return tx.orders.update(command.orderId, {\n ...command.patch,\n revision: order.revision + 1\n });\n });\n}\nЗдесь 400, 422, 403, 404 и 409 — выбранные значения учебного API. RFC 9110 описывает 409 Conflict как конфликт с текущим состоянием ресурса, а 422 Unprocessable Content — как синтаксически корректное содержимое, инструкции которого сервер не может обработать. Если ресурс отдаёт ETag, HTTP предлагает условие If-Match для защиты от потерянного обновления; провал этого precondition обычно выражают 412. Не смешивайте этот стандартный механизм с собственным полем revision без явного контракта.
Код не является готовым ORM-рецептом. При отсутствии блокирующего чтения можно использовать атомарное обновление с условием WHERE id = ? AND revision = ? AND state <> 'paid' и проверить число изменённых строк. Ошибка сети после записи тоже требует решения: без idempotency key или операции, которую можно безопасно найти повторно, клиент не знает, была ли команда применена.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
limit строкой проходит в handler | Схема не подключена к runtime или проверяется не тот объект | Вызвать реальный handler с неправильным типом | Остановить запрос до доменного кода |
| Схема пропускает обратный диапазон | Связь полей не закреплена в assertion-правиле | Запустить чистый тест from > to | Вернуть 400/422 с кодом причины |
| Правильная команда получает отказ без записи | Актор не проходит политику ресурса | Проверить actor, ресурс и решение authorization | Вернуть 403, не повторять тот же запрос |
| Повторная команда иногда меняет уже изменённый заказ | Проверка ревизии была до транзакции или нет идемпотентности | Повторить команду при конкурентном обновлении и потере ответа | Сделать условную запись и определить безопасный retry |
| Клиент бесконечно повторяет конфликт | 409/412 смешан с временным сетевым отказом | Сверить статус, reason code и факт изменения ресурса | Перечитать состояние или остановить retry |
required, типы, границы, enum, политику дополнительных ключей и поведение format. Положительный и отрицательный примеры храните рядом.role из тела запроса не заменяет доверенный контекст.validation failed.JSON Schema не превращается в движок доменных правил от добавления новых keywords. Пользовательские vocabulary и расширения могут быть полезны, но их должны одинаково понимать все участники контракта. Иначе документ будет выглядеть строгим в одном валидаторе и почти свободным в другом.
\nБлокировка строки и условное обновление — разные реализации одной цели. Их поведение зависит от базы, драйвера, уровня изоляции, таймаутов и обработки deadlock. Учебный вызов getForUpdate не доказывает отсутствие гонки; это нужно подтвердить тестом на конкурентные операции и проверкой числа записанных строк.
HTTP-статус не сообщает всей причины. 409 может требовать перечитать ресурс, 412 — обновить условие, 403 — прекратить попытку, а 422 — исправить смысл команды. В теле ответа нужны стабильный код причины и данные, которые клиенту разрешено показать. Политику retry нельзя выводить только из класса 4xx или 5xx.
Наконец, успешная проверка документа не доказывает, что операция безопасна во всех сценариях. Надёжный вывод скромнее: неправильная форма остановлена на границе, локальное правило имеет отрицательный тест, полномочия проверены по доверенному контексту, а конкурентная запись не проходит без определённого конфликта.
\nEndpoint готов к проверке, когда команда может показать положительный сценарий и отрицательные ветки. Правильная форма проходит. Неправильный тип или лишний ключ останавливаются схемой. Обратный диапазон останавливается инвариантом. Запрещённый актор получает 403. Устаревшая ревизия или оплаченный заказ не меняются и возвращают конфликт. Потеря ответа не приводит к слепому повторному побочному эффекту. Для каждой ветки указаны источник контекста, reason code, статус и следующий шаг клиента.
\nЕсли тест схемы проходит, а handler принимает другой документ, контракт не подключён. Если два параллельных запроса оба записывают одну ревизию, граница состояния стоит слишком рано. Если клиент повторяет 409/412 до бесконечности, API не различает конфликт и временный сбой. Эти наблюдаемые проверки возвращают статью к исходному вопросу: валидный JSON — необходимое условие, но не разрешение на операцию.
\nformat. Сверяйте по нему обязательность полей, дополнительные свойства и настройку format assertion.If-Match. Используйте стандарт для HTTP-семантики, а reason code и retry policy определяйте в своём API.Сервис возвращает 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Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах видны неизвестное значение enum, отсутствие поля или вызов метода у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки — поиск версии клиента, срочный откат и проверка кэшей или данных, если изменение затронуло их формат.
\nПроблема возникает, когда форму ответа считают внутренней деталью. Удаление свойства, изменение типа, добавление обязательного поля и новое значение enum меняют контракт по-разному. Ниже — способ проверить один HTTP-ответ до релиза: сначала зафиксировать границу, затем прогнать старого потребителя и только после этого выбирать совместимое расширение или новую версию.
\nВ учебном примере граница — GET /customers/{id}, статус 200, media type application/json и тело ответа. Направление тоже входит в контракт: request отправляет клиент, response читает клиент. Поэтому обязательное поле в запросе и обязательное поле в ответе нельзя оценивать одним правилом.
OpenAPI описывает HTTP-операцию, её ответы и доступную потребителю форму интерфейса. JSON Schema проверяет экземпляр JSON по типам, обязательным полям и ограничениям. Эти инструменты отвечают на разные части вопроса. Ни один из них сам по себе не доказывает, что фактический handler отдаёт описанное тело, что у пользователя есть право на ресурс или что ревизия записи ещё актуальна.
\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}\nHTTP 200 подтверждает успешную обработку запроса на уровне протокола, но не совместимость представления со старым кодом. Потребитель может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Синтаксически правильный JSON всё равно ломает клиент, если изменились тип, допустимые значения или смысл поля.
| Изменение | Что ломается | Проверка | Действие |
|---|---|---|---|
response: поле удалили или переименовали | Старый клиент обращается к property | Найти чтения поля и старые fixtures | Сохранить поле на deprecated-период или выпустить версию |
response: изменили тип или смысл | Десериализатор или бизнес-ветка принимает неверное значение | Проверить тип и поведение старого клиента | Добавить новое поле с новым именем или сохранить семантику |
response: добавили значение enum | Строгий decoder или ветка по умолчанию не знает значение | Прогнать старый код на каждом допустимом значении | Не включать значение в старый контракт или подготовить новую версию |
request: добавили required-поле | Старый отправитель получает отказ | Отправить новую форму без поля | Сделать поле optional, дать default или изменить версию |
response: добавили optional-поле | Обычно ничего, но strict decoder может отклонить неизвестный ключ | Проверить реальную политику неизвестных полей | Зафиксировать поведение decoder и добавить contract-test |
Слово breaking относится не к строке diff, а к конкретному потребителю и направлению обмена. Новое поле в response обычно расширяет контракт, если старый decoder игнорирует неизвестные ключи. Но схема с additionalProperties: false или строгая библиотека могут сделать такое расширение несовместимым. Решение принимают по исполняемому правилу клиента, а не по названию изменения.
Функция ниже получает уже разобранный JavaScript-объект. Она не ходит в сеть, не читает базу и не проверяет право доступа. Валидатор извлекает известные поля и возвращает ясную причину отказа. Дополнительные ключи он не использует и не объявляет допустимыми: политику strict или permissive нужно задать отдельной схемой и тестом.
\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.trim() === '') {\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 unknownState = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'deleted',\n});\nconst wrongRevision = validateCustomerResponse({\n id: 'customer-17', revision: '4', state: 'active',\n});\n\nconsole.log(accepted.ok, accepted.value.state);\n// true active\nconsole.log(unknownState.ok, unknownState.reason);\n// false state-is-outside-enum\nconsole.log(wrongRevision.ok, wrongRevision.reason);\n// false revision-must-be-positive-integer\nОтрицательные случаи показывают, где остановился контракт. state: deleted не должен тихо попасть в ветку для активного клиента, а строка "4" не должна превратиться в число без явного правила. Если обязательное поле исчезло, причина должна назвать его. Такой адаптер полезен на границе, но его зелёный результат не заменяет вызов реального HTTP-маршрута.
Content-Type и тело.400 для неверной формы, 403 для отказа в праве, 404 для отсутствующего ресурса, 409 для конфликта состояния и 412 для невыполненного условия If-Match.JSON Schema хорошо описывает документ: типы, обязательность, enum и дополнительные свойства. Она не видит пользователя, базу и время. Ответ с state: active может соответствовать схеме, хотя запись уже заблокирована. revision: 4 не доказывает, что обновление поверх ревизии 3 ещё разрешено.
Состояние и право требуют отдельного протокола. Для авторизации сервис проверяет роль и возвращает согласованный отказ. Для конкурентного обновления он может использовать версию ресурса и условный запрос с If-Match; если условие не выполнено, клиенту нужен отдельный сигнал 412 Precondition Failed. Конфликт доменного состояния может быть 409 Conflict. Не маскируйте эти случаи под 400: форма запроса может быть правильной, а причина отказа — в праве или текущем состоянии.
Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении или на редком кодовом пути. Поэтому contract-test должен вызвать маршрут и проверить фактические статус, заголовок и тело. Runtime-проверка снижает риск несовместимого payload, но не доказывает, что список потребителей полон.
\nУчебный валидатор не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои, кэш и содержимое базы. Он также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.
\nИзменение готово к выпуску, когда команда может показать четыре доказательства:
\nЕсли одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно. Возьмите один настоящий endpoint, заведите для него положительный и отрицательные fixtures, а затем повторите проверку после изменения схемы. Такой маленький контур быстрее полного аудита и оставляет след, который можно повторить в CI.
\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 для конкретного сервиса. |
Представьте endpoint GET /profile?id=.... Пользователь u-1 запрашивает свой профиль и получает 200, затем меняет один идентификатор и получает профиль u-2 с тем же статусом. Интерфейс не показывает кнопку для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого.
Это учебный сценарий, а не отчёт о конкретном инциденте. Его задача — показать, как превратить фразу «авторизация проверена» в воспроизводимое доказательство. Для одного endpoint мы свяжем требование, субъект, действие, объект, ожидаемый HTTP-ответ и фактический отрицательный тест.
\nАутентификация отвечает на вопрос «кто пришёл». Авторизация отвечает на вопрос «может ли этот субъект выполнить действие над этим объектом». Валидная сессия не даёт доступа ко всем записям, а роль сама по себе не доказывает владение объектом.
\nВозьмём требование AUTH-PROFILE-01: пользователь может прочитать свой профиль, но не профиль другого пользователя. В этой статье закрепим учебный HTTP-контракт. Для известного чужого профиля выберем 403, чтобы пример был однозначным; если продукт скрывает существование объекта, команда может выбрать 404, но тогда это значение нужно одинаково записать в policy, adapter и тест.
| Субъект | Действие | Объект | Ожидаемый ответ |
|---|---|---|---|
| user u-1 | read | profile u-1 | 200, доступ разрешён |
| user u-1 | read | profile u-2 | 403, доступ запрещён |
| guest u-1 | read | profile u-1 | 403, роль не разрешена |
| без проверенного субъекта | read | profile u-1 | 401, нужен challenge |
| admin a-1 | read | audit | 200, доступ разрешён |
| user u-1 | read | несуществующий объект | 404, объект не найден |
Статус — часть контракта, а не украшение отчёта. 401 относится к отсутствию действительного контекста аутентификации, 403 — к распознанному запросу без нужного разрешения. 404 означает отсутствие представления или сознательное сокрытие его существования. В тесте нельзя оставлять формулировку «403 или 404»: она не даёт команде проверяемого результата.
\nСхема ниже показывает минимальную петлю доказательства: отрицательный вход должен дойти до assert, а расхождение возвращается в исправление требования или policy.
\nСсылка на объект может быть числом, UUID или slug. Непредсказуемый идентификатор полезен как дополнительная мера, но не заменяет проверку доступа. Если endpoint получает id из URL и сразу делает поиск по нему, пользователь может подставить соседнее значение.
| Поле | Доверенный источник | Опасная подмена |
|---|---|---|
| subjectId | проверенный контекст аутентификации | userId из body или query |
| role | проверенные claims и серверная политика | роль из заголовка клиента |
| action | метод и маршрут endpoint | значение из произвольного поля формы |
| ownerId | запись ресурса и доменное хранилище | ownerId, присланный клиентом |
| tenantId | контекст субъекта и серверная запись | tenant из URL без проверки принадлежности |
Сначала получите субъект из уже проверенного контекста. Затем загрузите ресурс в нужной области данных и определите его владельца на сервере. Поле ownerId из тела запроса не является доказательством владения: клиент может поменять его перед отправкой.
Политика должна разрешать узкие комбинации и отказывать во всём неизвестном. Проверка только роли пропускает горизонтальную границу между двумя пользователями. Проверка только токена отвечает на вопрос аутентификации, но не на вопрос доступа к записи.
\nfunction decide({ role, subjectId, action, resource }) {\n if (typeof role !== 'string' || typeof subjectId !== 'string' ||\n typeof action !== 'string' || !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. Внутренняя reason помогает диагностике, но клиенту безопаснее отдавать общий класс ошибки без сведений о policy и существовании чужой записи.
В production policy должна применяться для каждого действия, которое принимает ссылку на объект: чтения, изменения, удаления, экспорта и административной операции. Если правило действует только в GET-handler, тот же объект может остаться доступным через PATCH или batch endpoint.
\nUnit-тест чистой функции полезен, но не ловит ошибку в middleware, загрузчике данных, сериализаторе или кэше. Следующий минимальный fixture запускает локальный HTTP-сервер, получает объект из серверной Map и проверяет реальный ответ. Заголовки x-subject и x-role здесь лишь заменяют проверенный контекст для примера; в настоящем сервисе клиент не должен сам определять эти значения.
import { createServer } from 'node:http';\nimport assert from 'node:assert/strict';\n\nconst profiles = new Map([\n ['u-1', { ownerId: 'u-1' }],\n ['u-2', { ownerId: 'u-2' }],\n]);\n\nfunction decide({ role, subjectId, action, resource }) {\n if (typeof role !== 'string' || typeof subjectId !== 'string' ||\n typeof action !== 'string' || !resource) {\n return { status: 'deny', reason: 'invalid-input' };\n }\n if (role === 'admin' && action === 'read' && resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' && resource.ownerId === subjectId) {\n return { status: 'allow', reason: 'object-ownership' };\n }\n return { status: 'deny', reason: 'default-deny' };\n}\n\nconst server = createServer((request, response) => {\n const url = new URL(request.url, 'http://local');\n const id = url.searchParams.get('id');\n const profile = profiles.get(id);\n const resource = id === 'audit'\n ? { kind: 'audit' }\n : profile && { kind: 'profile', ownerId: profile.ownerId };\n const subjectId = typeof request.headers['x-subject'] === 'string'\n ? request.headers['x-subject']\n : undefined;\n const role = typeof request.headers['x-role'] === 'string'\n ? request.headers['x-role']\n : undefined;\n const action = request.method === 'GET' ? 'read' : 'unknown';\n const decision = decide({ role, subjectId, action, resource });\n const status = !subjectId ? 401\n : !resource ? 404\n : decision.status === 'allow' ? 200\n : 403;\n\n response.writeHead(status, {\n 'content-type': 'application/json',\n ...(status === 401 ? { 'www-authenticate': 'Bearer' } : {}),\n });\n response.end(JSON.stringify({ allowed: decision.status === 'allow' }));\n});\n\nawait new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));\nconst { port } = server.address();\nconst cases = [\n { name: 'owner', path: '/profile?id=u-1', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 200 },\n { name: 'foreign profile', path: '/profile?id=u-2', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 403 },\n { name: 'unknown role', path: '/profile?id=u-1', headers: { 'x-subject': 'u-1', 'x-role': 'guest' }, status: 403 },\n { name: 'unknown object', path: '/profile?id=u-9', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 404 },\n { name: 'anonymous', path: '/profile?id=u-1', headers: {}, status: 401 },\n { name: 'admin audit', path: '/profile?id=audit', headers: { 'x-subject': 'a-1', 'x-role': 'admin' }, status: 200 },\n];\n\ntry {\n for (const test of cases) {\n const response = await fetch('http://127.0.0.1:' + port + test.path, { headers: test.headers });\n assert.equal(response.status, test.status, test.name);\n console.log('PASS', test.name);\n }\n} finally {\n server.close();\n}\nСохраните блок в файл authorization-check.mjs и запустите командой node authorization-check.mjs на Node 18 или новее. Он напечатает шесть строк PASS. В ответе нет внутренней причины отказа. На реальном endpoint дополнительно проверьте тело ошибки, заголовки и отсутствие побочного действия, а не только число статуса.
Положительный unit-тест может быть зелёным, даже если реальный маршрут уязвим. Policy могла вернуть deny, а adapter — превратить его в 200. Репозиторий мог загрузить запись другого tenant до проверки. Кэш мог сохранить приватный ответ по ключу profile:42 и отдать его следующему субъекту.
Проверка кэша — отдельный отрицательный сценарий: прогрейте ответ от u-1, повторите тот же запрос от u-2 и сравните статус и тело. Для приватного ответа проще запретить общий кэш; если кэш нужен, его ключ и политика должны учитывать все атрибуты, влияющие на доступ. Это проектное решение нельзя объявить безопасным без теста.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не владельца | u-1 запрашивает объект u-2 напрямую | Добавить object-level deny до выдачи |
| Пользователь читает audit | Роль проверяют по наличию | Сравнить user и admin на одном маршруте | Записать разрешённые resource/action явно |
| Неизвестная роль получает доступ | Ветка по умолчанию разрешает | Подать guest и пустой role | Оставить deny последней веткой |
| UI-тест зелёный, API уязвим | Проверяли скрытую кнопку | Повторить запрос без браузера | Тестировать handler и HTTP-границу |
| После прогрева виден чужой ответ | Ключ кэша не разделяет контекст | Повторить запрос разными субъектами | Разделить ключи или отключить кэш |
Fixture не проверяет настоящий токен, базу, reverse proxy, tenant isolation, CSRF, race condition или сетевую конфигурацию. Он доказывает только заявленный учебный маршрут. Случайный UUID не закрывает IDOR, а зелёный unit-тест не доказывает безопасность реального сервиса. Эти границы нужно назвать в отчёте, чтобы результат не расширился до необоснованного «авторизация безопасна».
\nПроверку можно закрыть, когда есть версия требования, матрица входов, прямой HTTP-тест и фактический результат каждой строки. Владелец получает 200, чужой объект — закреплённый deny-ответ, анонимный запрос — 401 с challenge, неизвестный ресурс — 404. Повтор после кэша не меняет результат между субъектами, а клиент не видит внутреннюю причину policy.
\nПользователь входит в систему, открывает /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Пользователь вошёл в систему и запросил /profile?id=u-2 вместо своего u-1. Токен действителен, поэтому сервер вернул чужую запись с кодом 200. Это горизонтальная эскалация прав: субъект прошёл аутентификацию, но получил объект, который ему не принадлежит. Цена ошибки — раскрытие персональных данных, изменение чужих записей и потеря границы между арендаторами (tenant-ами).
Проблема возникает там, где идентификатор из пути или строки параметров URL сразу передают в репозиторий. Сам факт, что пользователь знает URL и имеет рабочую сессию, не доказывает право на выбранный объект. Ниже — модель проверки, локальный воспроизводимый пример и набор отрицательных случаев. Код использует только фиктивные данные и не обращается к сети.
Аутентификация устанавливает субъекта: система проверяет учётные данные и связывает запрос с subjectId. Авторизация отвечает на другой вопрос: может ли этот субъект выполнить конкретное действие над конкретным объектом. Валидный токен даёт контекст личности, но не превращает каждый известный идентификатор в разрешённый.
В Broken Object Level Authorization (BOLA) доступ к функции уже предполагается: обычный пользователь вправе открыть endpoint профиля, но подменяет идентификатор и видит чужую запись. Это отличается от Broken Function Level Authorization (BFLA), когда тот же пользователь добирается до административной функции или меняет метод с GET на запрещённый DELETE. В реальном маршруте нужны обе проверки.
Роль помогает выбрать класс разрешений, но не описывает принадлежность каждой записи. Два субъекта могут иметь роль user и разные профили. Для решения нужны как минимум субъект, действие, объект и условия области доступа. Владелец, tenant и чувствительные свойства должны приходить из доверенного источника данных, а не из тела запроса.
Соберите контекст до вызова политики и явно отметьте источник каждого поля. Идентификатор из URL только выбирает кандидата. Он не назначает ему владельца и не меняет область хранения. Такой контракт легче просмотреть в коде и превратить в матрицу тестов.
| Поле | Доверенный источник | Что проверяем | Чего не делаем |
|---|---|---|---|
subjectId | Проверенная сессия или токен | Кто отправил запрос | Не читаем из URL и тела запроса |
role | Проверенные утверждения токена (claims) и политика | Какие функции доступны роли | Не принимаем роль от клиента |
action | Маршрут и HTTP-метод | Явное действие: read, write | Не разрешаем действие веткой «по умолчанию» |
resource | Серверная загрузка | Какой объект найден | Не доверяем сериализованному объекту клиента |
ownerId | Поле доменной записи | Совпадает ли владелец с субъектом | Не принимаем его из JSON-тела |
tenantId | Сессия и запись в хранилище | Одна ли это область доступа | Не разрешаем поиск в чужой области |
Политика должна возвращать отказ, если не совпала ни одна разрешённая комбинация. Неизвестная роль, действие, объект или область не должны попадать в «мягкую» ветку. На практике это означает список разрешений и последний результат deny, а не набор исключений, в котором легко забыть новый endpoint.
Политику вызывают на каждом бизнес-действии, а не только при отрисовке кнопки. Скрытый элемент интерфейса улучшает навигацию, но запрос можно отправить напрямую через HTTP-клиент. Middleware может подготовить субъект, handler — определить действие, репозиторий — вернуть запись, а слой политики — принять решение. Ни один из этих слоёв не должен подменять поля, которыми владеет другой.
Сохраним пример как access-check.cjs и запустим командой node access-check.cjs. Функция получает URL и уже проверенный контекст сессии. Заголовки здесь не используются: передача роли из клиентского заголовка была бы частью уязвимого примера, а не защитой. Map заменяет базу только для демонстрации.
const assert = require('node:assert/strict');\n\nconst profiles = new Map([\n ['u-1', { id: 'u-1', ownerId: 'u-1', tenantId: 't-1', displayName: 'Профиль u-1' }],\n ['u-2', { id: 'u-2', ownerId: 'u-2', tenantId: 't-1', displayName: 'Профиль u-2' }],\n ['u-3', { id: 'u-3', ownerId: 'u-3', tenantId: 't-2', displayName: 'Профиль u-3' }],\n]);\n\nfunction decide({ subjectId, role, tenantId, action, resource }) {\n if (!resource) return { status: 404, reason: 'not-found' };\n\n const canReadOwnProfile =\n role === 'user' &&\n action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId;\n\n if (canReadOwnProfile) return { status: 200, reason: 'owner' };\n return { status: 403, reason: 'default-deny' };\n}\n\nfunction getProfile({ requestUrl, subjectId, role, tenantId }) {\n const id = new URL(requestUrl, 'http://local').searchParams.get('id');\n const stored = profiles.get(id);\n const resource = stored && { kind: 'profile', ...stored };\n const decision = decide({\n subjectId,\n role,\n tenantId,\n action: 'read',\n resource,\n });\n\n const body = decision.status === 200\n ? { id: stored.id, displayName: stored.displayName }\n : { error: decision.status === 404 ? 'not-found' : 'forbidden' };\n return { status: decision.status, body };\n}\n\nconst cases = [\n {\n name: 'own profile',\n input: { requestUrl: 'http://local/profile?id=u-1', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 200, body: { id: 'u-1', displayName: 'Профиль u-1' } },\n },\n {\n name: 'foreign owner',\n input: { requestUrl: 'http://local/profile?id=u-2', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'foreign tenant',\n input: { requestUrl: 'http://local/profile?id=u-3', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'unknown role',\n input: { requestUrl: 'http://local/profile?id=u-1', subjectId: 'u-1', role: 'guest', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'missing object',\n input: { requestUrl: 'http://local/profile?id=u-9', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 404, body: { error: 'not-found' } },\n },\n];\n\nfor (const item of cases) {\n assert.deepEqual(getProfile(item.input), item.expected, item.name);\n console.log(item.name + ': ' + item.expected.status);\n}Ожидаемый вывод содержит статусы 200, 403, 403, 403 и 404. В разрешённом ответе наружу попадают только id и отображаемое имя. ownerId, tenantId и внутренний reason не становятся частью API-ответа.
Важна не только строка с условием. subjectId, роль и tenant в примере обозначают уже проверенный контекст. Если реальный обработчик заполнит их из пользовательского JSON или недоверенного заголовка, локальная функция перестанет доказывать нужное свойство. На границе HTTP нужно отдельно проверить подпись и срок жизни токена, а затем передать результат проверки в policy.
Для tenant-системы область лучше включать в запрос к хранилищу: SELECT id, owner_id, tenant_id FROM profiles WHERE id = :id AND tenant_id = :tenant. Так репозиторий не возвращает запись из чужой области обычному обработчику. Это не отменяет policy: роль, действие и владелец всё равно требуют проверки. Но граница данных появляется раньше сериализации, журналирования и работы с кэшем.
Проверка владельца после широкого поиска может выглядеть безопасно, если ответ всегда отбрасывается. Она всё равно усложняет защиту: объект уже попал в память процесса, ошибочный лог или промежуточный кэш. В запросах на изменение дополнительно разрешайте только перечисленные свойства. Поле ownerId, tenant и признаки статуса не должны обновляться массовой привязкой тела запроса.
Код ответа не заменяет policy, но помогает не смешивать границы. Отсутствующие или недействительные учётные данные относятся к 401. Сервер понял запрос, но не разрешил действие над известным объектом, — это кандидат на 403. Когда публикация самого факта существования записи опасна, сервис может вернуть 404 и для запрещённого объекта. Выбор должен быть единым для endpoint-а и модели угроз, а не случайной реакцией разных обработчиков.
401: слой аутентификации не получил действительных учётных данных; до объектной политики запрос обычно не дошёл.403: субъект распознан, но комбинация роли, действия, объекта или области не разрешена.404: запись отсутствует либо сервис не раскрывает, что запрещённый ресурс существует.Внешнее сообщение можно сделать одинаковым для нескольких отказов, а внутренний журнал — полезным для расследования. В него не должны попадать полный токен, пароль, секретные query-параметры и лишние персональные поля. Логируйте короткий класс причины, идентификатор операции и минимальный контекст, который разрешено хранить.
Положительный тест на свой профиль показывает только одну разрешённую строку. Он не проверяет, что граница закрыта для соседа, другой области или неизвестной роли. Матрица должна включать по меньшей мере такие наблюдаемые случаи:
| Вход | Ожидаемое решение | Что подтверждает тест |
|---|---|---|
user u-1 → profile u-1, read | allow / 200 | Легитимный сценарий не сломан |
user u-1 → profile u-2, read | deny, внешний 403 или 404 | Подмена id не даёт чужой объект |
user u-1 → profile u-3, другой tenant | deny, без данных записи | Область участвует в решении |
guest u-1 → profile u-1 | deny | Неизвестная роль не получает доступ |
user u-1 → audit или запрещённый метод | deny до бизнес-операции | Роль и действие не подменяются URL |
Запускайте такие проверки через настоящий маршрут и через прямой HTTP-клиент, без кликов в браузере. Сравнивайте код, тело и набор полей ответа для разрешённого и запрещённого случаев. Отдельно проверьте прокси и кэш: персональный ответ не должен попасть под общим ключом к следующему субъекту.
deny.401/403/404 и не возвращайте внутреннюю причину отказа без необходимости.Учебная функция не проверяет подпись JWT, срок жизни сессии, CSRF, атомарность транзакции, согласованность реплик, правила администратора, правила для отдельных полей и реальный кэш. Случайные или длинные идентификаторы могут затруднить перебор, но не заменяют проверку разрешений. Ни одна библиотека не знает автоматически, кому принадлежит объект в вашей предметной области.
Для одного endpoint-а работа готова, когда интеграционный тест через реальный маршрут разрешает свой объект, отказывает чужому и неизвестной роли, проверяет другую область и сравнивает тело ответа. У запретной ветки нет приватных полей, а решение не зависит от видимости UI-кнопки. Следующий шаг — взять один endpoint в окружении, похожем на production, записать матрицу «субъект × действие × объект» и сохранить эти отрицательные случаи как регрессионные тесты.
401, 403 и 404, включая возможность скрывать существование запрещённого ресурса через 404. Граница: RFC не выбирает policy и модель угроз продукта.Сервис принимает 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Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Проверка префикса https://cdn.example.test видит доверенное начало и пропускает запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.
Защита начинается до сетевого вызова: парсер строит структуру URL, политика проверяет схему, credentials, hostname и порт, а сетевой слой ограничивает фактический egress. Учебный валидатор ниже возвращает решение без DNS и HTTP. Далее отдельно разберём redirect, адреса A/AAAA и лимиты ответа, которые нельзя спрятать за одной функцией.
\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. Значит, правило должно описывать владение именами, а не похожесть строки. WHATWG URL Standard отдельно различает example.test и example.test.; не удаляйте завершающую точку молча: выберите каноническую форму конфигурации и закрепите её тестом.
Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.
\nПорт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.
Функция принимает строку URL и массив разрешённых имён. Сначала new URL строит разобранный URL, затем конфигурация один раз приводится к нижнему регистру и сравнивается с url.hostname. Результат имеет форму { allowed, reason, href }. Код не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy; локальные кейсы ниже проверяют порядок решений, но не доказывают безопасность 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 const allowlist = new Set(\n allowedHosts.map((host) => host.toLowerCase()),\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 (!url.hostname || !allowlist.has(url.hostname)) {\n return { allowed: false, reason: 'host' };\n }\n\n return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nconst allowlist = ['cdn.example.test'];\nconst cases = [\n ['valid', 'https://cdn.example.test/file.jpg', true, 'allowlist'],\n ['default-port', 'https://cdn.example.test:443/file.jpg', true, 'allowlist'],\n ['credentials', 'https://cdn.example.test@127.0.0.1/file.jpg', false, 'credentials'],\n ['loopback', 'https://127.0.0.1/file.jpg', false, 'host'],\n ['similar-host', 'https://evil-example.test/file.jpg', false, 'host'],\n ['wrong-scheme', 'http://cdn.example.test/file.jpg', false, 'scheme'],\n ['unexpected-port', 'https://cdn.example.test:8443/file.jpg', false, 'port'],\n ['invalid-url', 'not-a-url', false, 'invalid-url'],\n];\n\nfor (const [name, value, expectedAllowed, expectedReason] of cases) {\n const result = validateRemoteUrl(value, allowlist);\n if (\n result.allowed !== expectedAllowed\n || result.reason !== expectedReason\n ) {\n throw new Error(name + ': unexpected policy result');\n }\n console.log(\n name + ': ' + (result.allowed ? 'allow' : 'deny')\n + ' (' + result.reason + ')',\n );\n}\n// valid: allow (allowlist)\n// default-port: allow (allowlist)\n// credentials: deny (credentials)\n// loopback: deny (host)\n// similar-host: deny (host)\n// wrong-scheme: deny (scheme)\n// unexpected-port: deny (port)\n// invalid-url: deny (invalid-url)\nЗапуск печатает только имя кейса и решение. default-port показывает, что явный порт 443 после разбора совпадает с обычным HTTPS-адресом; credentials останавливает userinfo до проверки host; loopback и similar-host не проходят точный allowlist. Неверная схема, нестандартный порт и невалидная строка получают отдельные причины.
Это регрессионный тест парсера и политики, а не сетевой тест. allowed: true означает только, что разобранные поля совпали с конфигурацией. Перед передачей href клиенту задайте запрет автоматических redirect, timeout и лимит тела; DNS и egress проверяйте на отдельном слое. Полный входной 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, это нужно проверить в её документации и тесте.
\nСмена DNS-ответа между проверкой и соединением создаёт отдельную гонку. В контексте SSRF OWASP описывает DNS pinning и рекомендует мониторить, во что разрешённые имена превращаются по A и AAAA. Не называйте одну функцию защитой от DNS-перепривязки. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.
\nAllowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий 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 и результат. Эти сущности нельзя смешивать: 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В журнале часто остаётся только «request failed». По такой записи нельзя понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Команда видит шум, но не видит последовательность, которая его создала.
Цена ошибки — лишняя нагрузка для чтения и повторный побочный эффект для записи: второй заказ, два письма или повторное списание. Timeout добавляет неопределённость: сервер мог не получить запрос, мог его отклонить или уже применить, пока ответ потерялся. Поэтому строка «повторить при ошибке» недостаточна.
\nТезис: событие попытки должно связывать одну логическую операцию с конкретным сетевым вызовом, остатком общего времени и решением клиента. Тогда следующий retry можно проверить по полям, а не восстанавливать по догадкам.
\nОдна пользовательская операция может породить несколько HTTP-запросов. operationId создаётся на границе операции и остаётся прежним. requestId относится к одной сетевой попытке и меняется при retry. Поле attempt показывает порядок. Если генерировать все три идентификатора внутри низкоуровневого клиента, расследование потеряет связь между повторениями.
Ниже — минимальный локальный контракт. Его имена не являются готовыми семантическими соглашениями OpenTelemetry: команда должна согласовать типы, срок хранения, доступ и правила очистки отдельно. Важно сохранить смысл полей: ответ сервера не смешивается с исключением клиента, а причина не смешивается с действием.
\n| Поле | Пример | Что проверяет |
|---|---|---|
operationId | op-42 | Все попытки относятся к одной операции |
requestId | op-42/attempt-2 | Конкретный сетевой вызов и его след |
attempt | 2 | Порядок и фактическое число вызовов |
method | GET | Применимость политики повтора |
status | 503 или null | Ответ получен или его нет |
errorClass | timeout или null | Класс ошибки транспорта или клиента |
elapsedMs | 80 | Сколько заняла попытка |
remainingMs | 280 | Сколько общего бюджета было до вызова |
decision | retry | Какое действие выбрал клиент |
503 — это полученный HTTP-ответ. Он сообщает о недоступности сервиса в момент запроса, но не выбирает политику конкретного клиента. timeout — отсутствие ответа в отведённое время. Это не доказательство, что сервер не выполнил операцию. decision=retry — третье измерение: это уже решение вызывающей стороны, а не свойство ответа.
Для HTTP/3 транспорт может сообщить о состоянии соединения или потока, но прикладной протокол отдельно определяет смысл данных и ошибок. Поэтому закрытый поток не превращается автоматически в «заказ не создан». В событии нужно сохранить границу знания: что увидел клиент и какой результат остался неизвестным.
\n| Наблюдение | Что известно | Чего нельзя утверждать | Следующее действие |
|---|---|---|---|
200 после GET | Ответ получен, попытка завершилась | Что следующая попытка тоже нужна | Вернуть ответ и закрыть операцию |
503 после GET | Сервис ответил временной недоступностью | Что повтор безопасен для любого метода | Проверить метод, бюджет и лимит попыток |
timeout после GET | Клиент не получил ответ вовремя | Что запрос не был принят сервером | Рассмотреть ограниченный повтор чтения |
timeout после POST | Результат прикладной записи неизвестен | Что повтор создаст только одну запись | Проверить состояние по ключу операции |
| Deadline исчерпан до вызова | Новая попытка не начиналась | Что зависимость получила этот вызов | Вернуть terminal reason без сетевого запроса |
Для записи безопасное действие после timeout — не новый POST, а запрос состояния по согласованному ключу или ручное разбирательство. Такой переход можно назвать check_state. Он не утверждает успех или неуспех: он сохраняет неизвестный результат до отдельной проверки.
Пример ниже не открывает сеть. Массив observations заранее задаёт ответы и длительность, поэтому любой инженер может повторить последовательность событий на одной машине. Время здесь виртуальное: фиксированная задержка backoffMs нужна для демонстрации бюджета, а не является настройкой production-клиента.
function decideAttempt({ method, status, errorClass, remainingAfterMs, attemptsLeft, backoffMs }) {\n if (status >= 200 && status < 300) return 'return';\n if (errorClass === 'timeout' && method !== 'GET') return 'check_state';\n if (method === 'GET' && (status === 503 || errorClass === 'timeout')) {\n if (remainingAfterMs <= backoffMs) return 'deadline_exceeded';\n return attemptsLeft > 0 ? 'retry' : 'max_attempts';\n }\n return 'stop';\n}\n\nfunction runRetry({\n operationId,\n method,\n observations,\n maxAttempts = 3,\n deadlineMs = 400,\n backoffMs = 40,\n}) {\n let spentMs = 0;\n const events = [];\n const totalAttempts = Math.min(maxAttempts, observations.length);\n\n for (let index = 0; index < totalAttempts; index += 1) {\n const observation = observations[index];\n const remainingMs = Math.max(0, deadlineMs - spentMs);\n\n if (remainingMs === 0) {\n return { result: { status: 'deadline_exceeded' }, events };\n }\n\n const elapsedMs = Math.min(observation.elapsedMs, remainingMs);\n const timedOut =\n observation.errorClass === 'timeout' ||\n observation.elapsedMs > remainingMs;\n const status = timedOut ? null : observation.status ?? null;\n const errorClass = timedOut ? 'timeout' : observation.errorClass ?? null;\n const remainingAfterMs = remainingMs - elapsedMs;\n const decision = decideAttempt({\n method,\n status,\n errorClass,\n remainingAfterMs,\n attemptsLeft: totalAttempts - index - 1,\n backoffMs,\n });\n\n events.push({\n operationId,\n requestId: `${operationId}/attempt-${index + 1}`,\n attempt: index + 1,\n method,\n status,\n errorClass,\n elapsedMs,\n remainingMs,\n decision,\n });\n\n if (decision === 'retry') {\n spentMs += elapsedMs + backoffMs;\n continue;\n }\n\n return {\n result: decision === 'return' ? { status } : { status: decision },\n events,\n };\n }\n\n return { result: { status: 'max_attempts' }, events };\n}\n\nconst read = runRetry({\n operationId: 'op-42',\n method: 'GET',\n observations: [\n { status: 503, elapsedMs: 80 },\n { status: 503, elapsedMs: 80 },\n { status: 200, elapsedMs: 50 },\n ],\n});\n\nconst write = runRetry({\n operationId: 'op-43',\n method: 'POST',\n observations: [{ errorClass: 'timeout', elapsedMs: 120 }],\n});\n\nconsole.log(read.events, read.result, write.events[0].decision);\n// retry, retry, return; 200; check_state\nДля чтения пример создаёт три события. Остаток бюджета перед попытками равен 400, 280 и 160 миллисекундам; решения — retry, retry, return. Итоговый статус — 200. Для записи timeout даёт check_state, поэтому функция не запускает второй сетевой вызов.
Это узкая проверка порядка. В ней нет DNS, пула соединений, реального clock, конкурентных клиентов, server-side дедупликации и доставки событий. Именно поэтому результат примера нельзя называть измерением доступности или доказательством безопасности конкретного API.
\nЛокальный timeout ограничивает одну попытку, а deadline ограничивает всю операцию. Если каждая из трёх попыток получает по 400 миллисекунд, вызывающий код может ждать больше секунды. Правильная схема передаёт вниз один общий момент окончания и вычисляет remainingMs перед каждым вызовом:
remainingMs = deadlineMonotonic - monotonicNow
Время бюджета измеряют монотонными часами. Wall-clock пригоден для корреляции записей между узлами, но скачок системных часов не должен внезапно разрешить ещё одну сетевую попытку. В событии полезно хранить и elapsedMs, и остаток до вызова: одно показывает стоимость действия, другое — доступный запас.
Заголовок Retry-After нужно сохранять отдельным полем, например retryAfterMs. Сервер может подсказать задержку после 503, но клиент всё равно ограничивает её своим deadline, лимитом попыток и политикой метода. Подсказка о времени ожидания не превращает небезопасный POST в идемпотентную операцию.
maxAttempts и deadline отвечают на разные вопросы. Первый ограничивает количество вызовов, второй — время всей операции. Поэтому в терминальном событии стоит различать max_attempts и deadline_exceeded. Иначе команда начнёт увеличивать число попыток, когда на самом деле зависимость отвечает слишком медленно.
В нормальном расследовании сначала группируем записи по operationId, затем сортируем по attempt или времени начала. Внутри одной группы requestId должен быть уникальным для попытки. Если встречаются два события с одним requestId, проверяем повторную доставку логов; если меняется operationId, проверяем место создания идентификатора.
Последовательность 503 → 503 → 200 означает три наблюдаемых ответа, но не «сервис был полностью недоступен». Она подтверждает только ответы конкретной операции. Последовательность 503 → deadline_exceeded означает, что клиент остановился после первой попытки; это не новый ответ зависимости.
Отдельно считаем решения. Доля retry показывает поведение клиента, а доля timeout — наблюдаемый класс отказа. Не складывайте их в одну метрику ошибок: один timeout может привести к check_state, а один 503 — к безопасному ограниченному retry. Разные причины требуют разных действий.
Для наблюдаемости полезно разделить сигналы. Trace связывает путь запроса, log хранит конкретное событие, metric показывает распределение попыток и долю завершений по deadline. operationId и requestId не стоит бездумно превращать в labels метрики: высокая кардинальность сделает график дорогим и малоинформативным.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Везде только request failed | Статус, причина и решение слиты в строку | Найти status/errorClass/decision для одной операции | Разделить поля и ограничить их словарь |
remainingMs растёт после retry | Смешаны wall-clock и monotonic clock | Сверить бюджет, elapsedMs и точку начала операции | Считать deadline одной монотонной шкалой |
Есть decision=retry, но следующего вызова нет | Backoff или лимит попыток съел остаток бюджета | Проверить terminal reason и попытки после события | Записывать deadline/max_attempts отдельно |
| Одна операция получила два operationId | Идентификатор создаётся внутри retry-обёртки | Сопоставить входной запрос и все requestId | Создавать operationId до первого вызова |
503 повторяется для каждого метода | Политика смотрит только на статус | Сравнить method и доменный эффект | Запретить повтор без доказанной идемпотентности |
| В событии виден полный URL | Логируется вход без очистки | Проверить query, cookie, токены и тело | Нормализовать endpoint и удалить секреты |
operationId до первого вызова, выдавайте новый requestId каждой попытке и увеличивайте attempt.status, errorClass, decision и terminal reason. Не заменяйте их свободным сообщением.503 и 200, исчерпание deadline, timeout GET и timeout POST. Проверяйте события и число реальных вызовов.Фикстура использует заранее записанный timeline и не моделирует сетевую очередь, балансировщик, потерю логов, рестарт процесса, конкуренцию и реальные серверные побочные эффекты. В production время нужно брать из монотонного clock, а задержку — из согласованной политики зависимости. Фиксированные 40 миллисекунд в коде не являются универсальным backoff.
\nRFC описывает свойства HTTP-методов и транспортные границы, но не знает доменный эффект вашего endpoint. Даже идемпотентный метод может иметь неожиданные побочные действия из-за реализации. И наоборот, POST может получить отдельный idempotency-key контракт, но его срок, область уникальности, проверку тела и хранение результата должен доказать сервер.
Событие повышает наблюдаемость, но не подтверждает, что зависимость применила запись. Trace и log могут быть неполными, metric не заменяет конкретную попытку. Для платежа, заказа или уведомления владельцу операции нужен отдельный способ проверить состояние после неопределённого исхода.
\nИзменение готово, если фиксированный набор входов даёт одинаковую последовательность событий при повторном запуске. Для чтения 503 → 503 → 200 должны сохраниться один operationId, три разных requestId, номера попыток 1–3, остаток 400 → 280 → 160 и решения retry → retry → return. Итогом должен быть полученный 200, а не синтетический успех после исчерпания бюджета.
Для timeout GET проверяем ограничение числа вызовов и отдельный terminal reason. Для timeout POST проверяем один побочный вызов, решение check_state и отсутствие второго POST. В каждом событии есть либо status, либо errorClass, есть remainingMs и decision, а секреты не попадают в log или trace.
Этот критерий не обещает доступность зависимости и не доказывает корректность её транзакции. Он проверяет границу, которой управляет клиент: ограниченный retry не превращается в лавину, а неизвестный результат записи не превращается в новый побочный эффект.
\nRetry-After и статус 503 Service Unavailable. RFC не выбирает retry-политику конкретного API и не доказывает отсутствие побочного эффекта.