From d7f2ab1d54d3ee8ed834a3e3f89c31ffc9f0c96a Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 1 Sep 2026 23:19:40 +0300 Subject: [PATCH] editorial: integrate isolated chat revisions 011-016 --- editorial/agent-rewrites/011.json | 4 ++-- editorial/agent-rewrites/012.json | 2 +- editorial/agent-rewrites/013.json | 4 ++-- editorial/agent-rewrites/014.json | 6 +++--- editorial/agent-rewrites/015.json | 2 +- editorial/agent-rewrites/016.json | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) 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. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.

\n

Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, соответствует ли экземпляр описанной форме. Доменная проверка отвечает, согласованы ли поля между собой. Сервис проверяет текущий ресурс, полномочия и возможность применить команду. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке нужен только сам документ, её можно выполнить на входе. Как только нужны база, часы, права или другой запрос, это уже правило операции с отдельным результатом и отдельным тестом.

\n

Три вопроса вместо одного valid

\n

Начните с вопроса о форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа и одинакового выбранного диалекта она должна давать одинаковый результат.

\n

Затем проверьте связь полей. Диапазон дат требует, чтобы from не был позже to; сумма платежа должна соответствовать разрешённому типу операции; поле currency должно быть совместимо с суммой. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Часть таких условий выражается в JSON Schema через композицию и условные конструкции, если их поддерживает выбранный диалект и валидатор. Чистая функция часто читается лучше. Место проверки вторично по сравнению с тем, что правило названо и тестируется отдельно.

\n

Наконец, проверьте применимость к состоянию. Документ может быть безупречным, но ресурс уже изменился: revision: 4 устарела, купон исчерпан или заказ перешёл в необратимый статус. Это не ошибка типа JSON. Клиенту нужно перечитать ресурс, показать конфликт или завершить сценарий без повторной отправки.

\n
Слой проверки, источник контекста и следующий шаг
СлойЧто проверяемПример отказаГде выполнять
ФормаТип, обязательность, диапазон, enumlimit не integerJSON Schema или валидатор на входе
ИнвариантСвязь нескольких полейfrom позже toЧистая доменная функция
СостояниеАктуальность и доступность ресурсаРевизия уже измениласьРепозиторий внутри согласованной операции
ПравоРазрешение на командуРоль не может отменить заказПолитика авторизации до изменения состояния
\n

Что именно описывает JSON Schema

\n

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

\n

Слово «формат» требует осторожности. В JSON Schema Draft 2020-12 vocabulary format разделён на аннотацию и assertion: реализация может собирать информацию о формате, но не обязана отклонять экземпляр только из-за неё, если не включён режим проверки форматов. Поэтому для дат, URI и email нужно зафиксировать диалект, библиотеку и настройки. Если дата важна для домена, дополните схему явным правилом и тестом, а не рассчитывайте на одинаковое поведение всех валидаторов.

\n

Неизвестные поля требуют отдельного решения. Закрытый объект с additionalProperties: false ловит опечатку, но усложняет расширение контракта. Открытый объект легче развивать, но ошибочный ключ может быть молча проигнорирован потребителем. Выберите политику для конкретного endpoint, внесите её в схему и проверьте отрицательным примером. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12, но является его надмножеством с собственным диалектом; не переносите предположения о поведении одной реализации в другую.

\n
\"Матрица
Один документ проходит несколько границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.
\n

Самодостаточная проверка формы и инварианта

\n

Ниже — запускаемый учебный фрагмент без HTTP-сервера, базы и внешних пакетов. Объект filterSchema показывает намерение контракта, а небольшая функция имитирует только нужный для примера набор проверок. Это не реализация JSON Schema и не замена библиотеке: цель фрагмента — сделать видимым порядок «форма → инвариант» и дать отрицательные случаи, которые можно воспроизвести командой node validation-example.mjs.

\n
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, а функцию диапазона оставьте чистой и вызывайте после успешной проверки формы.

\n

Состояние, права и гонка между чтением и записью

\n

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

\n

Практический вариант — передавать ожидаемую ревизию и делать условное обновление внутри транзакции: обновить запись только при совпадении идентификатора и версии, увеличить версию атомарно, а отсутствие обновлённой строки превратить в конфликт. Точный SQL, блокировки и уровень изоляции зависят от СУБД. Поэтому пример с getForUpdate нельзя копировать без проверки драйвера, а тест должен запускать два конкурентных изменения, а не только вызывать функцию два раза подряд.

\n

Право — ещё один отдельный источник контекста. Ответ 403 говорит, что сервер понял запрос, но не разрешает действие этому субъекту; он не исправляется изменением revision. Если политика зависит от владельца, организации или состояния заказа, проверяйте её на том же представлении данных, для которого принимается решение. Не полагайтесь на скрытие кнопки в интерфейсе: клиент не является границей доверия.

\n

Для конфликта текущего ресурса RFC 9110 определяет 409 Conflict и ожидает от сервера достаточно сведений, чтобы распознать источник конфликта. Для HTTP-precondition, заданного заголовками вроде If-Match, применяется отдельная семантика 412 Precondition Failed. Выбранный статус должен соответствовать реальному контракту API, а тело — содержать стабильный код причины и безопасное действие: перечитать ресурс, объединить изменения или прекратить повтор.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
Валидатор принимает ключ, а клиент его не используетСхема открыта или контракт не обновилиСверить политику unknown keys и чтение поляЗакрыть объект либо документировать расширение
Правильный JSON получает 400Ошибка состояния скрыта под ошибкой формыРазделить логи shape, invariant и stateВернуть отдельный класс ошибки и исправить retry
Повторный запрос меняет уже изменённый ресурсНет идемпотентности или проверки версииПовторить команду при конкурентном обновленииДобавить ключ идемпотентности или условие версии
Тест схемы проходит, endpoint падаетСхема не покрывает runtime-веткуВызвать реальный handler с теми же даннымиДобавить интеграционный тест на границе
Клиент бесконечно повторяет запросКонфликт обозначен как временная ошибкаПроверить статус, код причины и retry policyРазличить повторяемый отказ и конфликт ресурса
\n

Порядок внедрения

\n
  1. Назовите endpoint, метод, формат запроса и ожидаемые ответы. Не начинайте с общей функции validate: сначала определите документ и команду.
  2. Выпишите типы, обязательность, enum, диапазоны, политику неизвестных ключей и диалект схемы. Сохраните положительный и отрицательный экземпляры.
  3. Отделите правила, которым нужен только вход, от правил, которым нужны два поля, время, права или данные ресурса.
  4. Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет, конфликт и временная недоступность не должны превращаться в один boolean.
  5. Зафиксируйте поведение format в выбранном валидаторе. Один и тот же ключ может быть аннотацией в одной конфигурации и отклонением в другой.
  6. Проверьте отрицательные пути: устаревшую ревизию, повтор команды, отсутствие ресурса, запрещённую роль и сетевой отказ. Для каждого укажите, должен ли клиент повторять запрос.
  7. Запустите unit-тест чистой функции и интеграционный тест реального handler. Для конкурентного изменения создайте два запроса с одной ожидаемой ревизией и подтвердите, что успешен только допустимый.
  8. Опишите безопасное расширение. При добавлении поля или значения enum проверьте старого потребителя и решите, нужен ли период совместимости.
  9. Зафиксируйте критерий готовности: каждый класс отказа имеет тест, HTTP-статус, код причины, наблюдаемую запись и понятный следующий шаг клиента.
\n

Ограничения подхода

\n

Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от нескольких систем и измениться между проверкой и записью. В таких случаях нужно описать границу согласованности и риск, а не расширять JSON Schema до роли универсального движка правил.

\n

HTTP-статус тоже не заменяет доменный контракт. 409 подходит для конфликта с текущим состоянием ресурса, но не обязан быть единственным выбором для каждого бизнес-отказа. 422 уместен, когда сервер понимает тип содержимого и не может обработать содержащиеся инструкции; конкретное применение согласуйте в API. Важна стабильная связь «причина → статус → действие», а не магическая цифра.

\n

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

\n

Критерий готовности

\n

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

\n

Проверяемые источники

" + "excerpt": "JSON Schema проверяет форму документа, но не знает права пользователя и текущее состояние ресурса. Разбираем границу между схемой, доменным правилом и безопасной записью.", + "contentHtml": "

Клиент отправляет команду на изменение заказа. Обязательные поля на месте, типы верны, JSON Schema пропускает документ. Через несколько миллисекунд сервис отвечает отказом: заказ уже оплачен, ревизия устарела или роль пользователя не даёт права на изменение. Если все ветки записать как validation failed, клиент может повторить необратимое действие, оператор увидит лишнюю операцию, а разработчик начнёт искать причину то в схеме, то в базе. Цена ошибки — потерянное время и риск изменить ресурс второй раз.

\n

Здесь слово «valid» отвечает только на вопрос о документе. Для операции нужны ещё три ответа: согласованы ли поля между собой, имеет ли актор право на действие и можно ли применить его к текущему состоянию. Практическое правило — назначать проверку по источнику контекста. Вход достаточно проверить схемой, связь полей — чистой функцией, права — политикой авторизации, а ревизию и переход состояния — внутри защищённой операции.

\n

Четыре вопроса вместо одного «valid»

\n

Форма. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из объявленных значений. Такая проверка читает только вход. Для одинакового документа она должна давать одинаковый результат и не обращаться к базе, часам или сети.

\n

Инвариант. Отдельные поля могут быть корректны, но противоречить друг другу. Диапазон требует, чтобы from не был позже to; команда отмены не должна одновременно содержать действие «оплатить». Если правило зависит только от входного объекта, его удобно выразить чистой функцией и покрыть положительным и отрицательным тестом.

\n

Право. Схема не знает, кто отправил запрос, к какому ресурсу относится роль и не отозвано ли разрешение. Проверка canEditOrder(actor, orderId) использует контекст субъекта и политики. Её результат нельзя получить из добавленного в JSON поля role: такое поле не доказывает подлинность полномочий.

\n

Состояние. Документ может быть безупречным, но заказ уже перешёл в paid, склад изменился или ревизия стала другой. Сервис должен проверить это рядом с записью — в транзакции или условном обновлении. Предварительное чтение без защиты от гонки оставляет окно, в котором другой запрос успеет изменить ресурс.

\n
Слой проверки и его граница
СлойНужный контекстПример отказаОтвет сервиса
ФормаТолько документlimit — строка400, исправить вход
ИнвариантНесколько полей документаfrom позже to400 или 422 по контракту
ПравоАктор и политика ресурсаРоль не может отменить заказ403, не повторять без изменения полномочий
СостояниеАктуальный ресурс и ревизияЗаказ уже оплачен409 или 412, перечитать либо разрешить конфликт
\n

Коды в последнем столбце — часть конкретного API, а не автоматический вывод схемы. Важно, чтобы клиент различал ошибку входа, запрет и конфликт состояния: у них разные исправления и разные правила повторной попытки.

\n

Где заканчивается JSON Schema

\n

JSON Schema описывает экземпляр документа: типы, обязательность, границы чисел, перечисления, структуру объектов и политику дополнительных ключей. Ключевой нюанс — наличие имени в properties не делает поле обязательным; для этого нужен required. Аналогично, additionalProperties: false ловит опечатки, но превращает добавление нового поля в изменение контракта. Решение о закрытой или расширяемой форме нужно принять для конкретного endpoint и закрепить тестом.

\n

У ключевого слова format есть отдельная граница. В Draft 2020-12 оно относится к vocabulary форматов-аннотаций; assertion-проверка формата включается отдельным vocabulary и поддержкой валидатора. Поэтому запись format: 'date' сама по себе не даёт права утверждать, что любой runtime отвергнёт несуществующую календарную дату. Если отказ обязателен, настройте валидатор явно или добавьте проверку, которую можно вызвать и протестировать.

\n

OpenAPI 3.1 использует Schema Object как расширение JSON Schema и помогает описать входы и выходы HTTP-интерфейса. Это документация контракта, а не доказательство того, что handler действительно проверяет те же поля. Схема, сгенерированные типы и runtime-валидатор должны быть связаны проверкой сборки или интеграционным тестом; один из них не заменяет остальные.

\n
\"Матрица
У одного запроса четыре независимые границы. Рисунок показывает источник контекста и пример класса ответа; точные коды закрепляются контрактом endpoint.
\n

Учебный пример: схема, чистое правило и условная запись

\n

Ниже — учебный фрагмент без HTTP-сервера и базы. В нём отдельно видны форма и межполевая проверка. Поле format оставлено намеренно: рядом есть явная проверка календарной даты, чтобы пример не зависел от скрытой настройки конкретной библиотеки.

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

\n
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 без явного контракта.

\n

Код не является готовым ORM-рецептом. При отсутствии блокирующего чтения можно использовать атомарное обновление с условием WHERE id = ? AND revision = ? AND state <> 'paid' и проверить число изменённых строк. Ошибка сети после записи тоже требует решения: без idempotency key или операции, которую можно безопасно найти повторно, клиент не знает, была ли команда применена.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
limit строкой проходит в handlerСхема не подключена к runtime или проверяется не тот объектВызвать реальный handler с неправильным типомОстановить запрос до доменного кода
Схема пропускает обратный диапазонСвязь полей не закреплена в assertion-правилеЗапустить чистый тест from > toВернуть 400/422 с кодом причины
Правильная команда получает отказ без записиАктор не проходит политику ресурсаПроверить actor, ресурс и решение authorizationВернуть 403, не повторять тот же запрос
Повторная команда иногда меняет уже изменённый заказПроверка ревизии была до транзакции или нет идемпотентностиПовторить команду при конкурентном обновлении и потере ответаСделать условную запись и определить безопасный retry
Клиент бесконечно повторяет конфликт409/412 смешан с временным сетевым отказомСверить статус, reason code и факт изменения ресурсаПеречитать состояние или остановить retry
\n

Порядок внедрения

\n
  1. Опишите команду. Зафиксируйте метод, endpoint, media type, обязательные поля и форму успешного ответа.
  2. Закройте вопрос формы. Выберите dialect, required, типы, границы, enum, политику дополнительных ключей и поведение format. Положительный и отрицательный примеры храните рядом.
  3. Вынесите инварианты. Для каждого правила укажите входы и результат. Если функция не читает внешний контекст, протестируйте её отдельно.
  4. Проверьте полномочия. Перед изменением назовите актора, ресурс и policy decision. Поле role из тела запроса не заменяет доверенный контекст.
  5. Защитите состояние. Сверяйте ревизию и допустимый state transition в транзакции или условном update. Проверьте отсутствие записи и конкурентное изменение.
  6. Разделите ответы. Для формы, инварианта, запрета и конфликта задайте reason code, HTTP status и следующий шаг клиента. Не отправляйте все ошибки в один validation failed.
  7. Определите повтор. Для безопасных операций допустим retry, для конфликта нужен новый read, а для команды с побочным эффектом — idempotency key или lookup результата.
  8. Проверьте реальную границу. Запустите handler с теми же данными, которыми тестировали схему. Убедитесь, что при каждом отрицательном сценарии запись не изменилась.
\n

Ограничения механизма

\n

JSON Schema не превращается в движок доменных правил от добавления новых keywords. Пользовательские vocabulary и расширения могут быть полезны, но их должны одинаково понимать все участники контракта. Иначе документ будет выглядеть строгим в одном валидаторе и почти свободным в другом.

\n

Блокировка строки и условное обновление — разные реализации одной цели. Их поведение зависит от базы, драйвера, уровня изоляции, таймаутов и обработки deadlock. Учебный вызов getForUpdate не доказывает отсутствие гонки; это нужно подтвердить тестом на конкурентные операции и проверкой числа записанных строк.

\n

HTTP-статус не сообщает всей причины. 409 может требовать перечитать ресурс, 412 — обновить условие, 403 — прекратить попытку, а 422 — исправить смысл команды. В теле ответа нужны стабильный код причины и данные, которые клиенту разрешено показать. Политику retry нельзя выводить только из класса 4xx или 5xx.

\n

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

\n

Критерий готовности

\n

Endpoint готов к проверке, когда команда может показать положительный сценарий и отрицательные ветки. Правильная форма проходит. Неправильный тип или лишний ключ останавливаются схемой. Обратный диапазон останавливается инвариантом. Запрещённый актор получает 403. Устаревшая ревизия или оплаченный заказ не меняются и возвращают конфликт. Потеря ответа не приводит к слепому повторному побочному эффекту. Для каждой ветки указаны источник контекста, reason code, статус и следующий шаг клиента.

\n

Если тест схемы проходит, а handler принимает другой документ, контракт не подключён. Если два параллельных запроса оба записывают одну ревизию, граница состояния стоит слишком рано. Если клиент повторяет 409/412 до бесконечности, API не различает конфликт и временный сбой. Эти наблюдаемые проверки возвращают статью к исходному вопросу: валидный JSON — необходимое условие, но не разрешение на операцию.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/012.json b/editorial/agent-rewrites/012.json index b03e9e4..9df59fb 100644 --- a/editorial/agent-rewrites/012.json +++ b/editorial/agent-rewrites/012.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", - "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки быстро становится операционной: приходится искать версии клиента, решать, можно ли откатить сервер, и проверять, не появились ли уже записи по новой схеме.

\n

Причина часто не в синтаксисе JSON. Команда меняет ответ как внутреннюю модель и не замечает внешних потребителей: удаляет свойство, меняет тип, сужает перечисление или объявляет новое свойство обязательным. Поэтому проверять нужно не только «валиден ли JSON», но и может ли прежний клиент разобрать ответ и выбрать ту же ветку поведения. Ниже — небольшой контракт для GET /customers/{id}, воспроизводимая проверка и критерий выпуска.

\n

Контракт начинается с границы HTTP

\n

До обсуждения полей зафиксируйте операцию: метод, путь, допустимый статус, Content-Type и тело ответа. OpenAPI описывает HTTP-интерфейс так, чтобы его могли читать люди и инструменты; это удобный источник договорённости, но не телеметрия реального сервера. Если handler иногда отвечает HTML-страницей ошибки или другой схемой при том же статусе, один файл OpenAPI этого не обнаружит.

\n

В примере успешное представление клиента имеет три обязательных поля. id — непустая строка, revision — положительное целое, state — одно из двух значений. Это именно внешний формат, а не копия таблицы в базе данных. Внутреннее поле updatedAt можно не публиковать; наоборот, публичное revision может быть вычисляемым. Такое разделение помогает не вынести внутреннюю миграцию наружу случайным изменением DTO.

\n
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}
\n

HTTP 200 говорит о результате операции на уровне протокола, но не обещает, что конкретная библиотека десериализации примет все значения. Клиент может строить URL из id, сравнивать ревизии или выбирать экран по state. Совместимость — это сохранение тех свойств и значений, на которые реально опирается старый потребитель.

\n

Какие изменения действительно опасны

\n

Термин breaking change нельзя применять к любому diff. Риск зависит от направления обмена и поведения клиента. Добавление необязательного свойства в ответ обычно переживает tolerant-клиент, но строгий декодер может отклонить неизвестное поле. Добавление нового значения enum не меняет JSON-тип, однако ломает закрытый switch, если клиент не имеет безопасной ветки по умолчанию. Поэтому таблица ниже — матрица для проверки, а не автоматический вердикт для всех библиотек.

\n
Изменение ответа, риск и доказательство совместимости
ИзменениеОбычный рискЧто проверитьРешение
Удалено свойствоBreakingЧтение поля, мапперы и fixtures старых клиентовСохранить поле на период совместимости или выпустить версию
Тип изменён: строка стала числомBreakingДесериализация и сравнения в старом клиентеДобавить новое свойство с новым типом
Добавлено обязательное свойство в ответBreaking для строгого декодераОбработка отсутствия поля и правила схемы клиентаСогласовать режим декодера; при запрете неизвестных полей — сменить версию
Добавлено новое значение enumУсловный breakingВетки старого клиента на каждом значенииРасширить обработчик либо не отправлять значение старой версии
Добавлено необязательное свойствоОбычно совместимоЗапрет неизвестных полей и влияние на размер ответаОставить расширение и добавить consumer-тест
Изменён смысл прежнего значенияСкрытый breakingПоведение, а не только JSON SchemaСохранить смысл или переименовать поле
\n

Отдельно проверяйте статус и заголовки. Ответ 404, который превратился в 200 с объектом ошибки, может сломать клиент раньше, чем тот доберётся до тела. И наоборот, формально одинаковый JSON при смене семантики статуса изменит ветку повторов и отображение ошибки. Для каждого исхода задайте точную пару «статус — форма тела».

\n

Проверяем форму до бизнес-правила

\n

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

\n

Ниже — намеренно маленький валидатор на уже разобранном объекте. Он не исправляет ответ молча и не подставляет отсутствующую ревизию. Для production-кода понадобятся проверка фактического HTTP-ответа, единый формат ошибок и согласованный с командой способ обработки лишних полей.

\n
function 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 действительно вызывает эту функцию или что сериализатор не меняет данные после проверки. Именно поэтому проверку объекта дополняют тестом маршрута с настоящим статусом, заголовком и телом.

\n
\"Поток
У совместимости три разные точки доказательства: форма ответа, поведение потребителя и контекст операции. Прохождение первой точки не подтверждает право доступа, актуальность данных или корректность бизнес-перехода.
\n

Consumer-тест проверяет не только декодирование

\n

Тест потребителя должен запускать старый клиент против нового ответа. Успешная десериализация недостаточна: нужно пройти ветку, которая использует поле. Для state это означает проверить active, blocked и поведение на неизвестном значении, если сервер имеет право его прислать. Для удаляемого свойства — убедиться, что старый клиент не строит на его отсутствии неверное значение по умолчанию.

\n

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

\n

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

\n

Воспроизводимый порядок проверки

\n
  1. Опишите endpoint, метод, допустимые статусы, заголовки и media type. Отделите публичное представление от внутренней модели.
  2. Сохраните текущий контракт как набор схем и примеров: успешный ответ, отсутствие записи, отказ в доступе и конфликт версии, если эти исходы предусмотрены.
  3. Соберите список потребителей и версий. Ищите чтения полей, enum-ветвления, строгие декодеры, DTO-мапперы и генерацию клиентов.
  4. Сравните старую и новую формы. Отдельно пометьте удаление, изменение типа, обязательность, enum, статус, заголовки и изменение смысла.
  5. Запустите схему и runtime-проверку на реальном сериализованном ответе. Не подменяйте HTTP-ответ вручную собранным объектом без отдельного теста сериализации.
  6. Прогоните consumer-тест на старой версии клиента. Проверяйте результат поведения и отрицательные случаи, а не только отсутствие исключения.
  7. Для несовместимого diff выберите одно действие: сохранить старое свойство, добавить новое рядом, открыть период совместимости или выпустить отдельную версию. Назначьте измеримое условие удаления.
  8. После выпуска наблюдайте использование старого поля и долю старых клиентов. Удаляйте deprecated-часть только после подтверждения, что условие выполнено.
\n

Каждый шаг должен оставлять артефакт: diff схемы, fixture, результат теста или метрику. Фраза «клиенты не жаловались» не является доказательством: она не показывает покрытые версии, редкие ветки и неактивных потребителей.

\n

Статус, версия и состояние — разные решения

\n

Схема ответа не заменяет протокол ошибки. Если запрос сформирован правильно, но ресурс изменился между чтением и записью, клиенту нужен сигнал конфликта состояния, а не сообщение о неверном JSON. RFC 9110 описывает условные запросы и заголовок If-Match; его можно использовать как часть отдельного контракта конкурентного обновления, если сервер проверяет условие до изменения.

\n

Авторизация, наличие ресурса и конкурентная версия имеют разные причины и обычно разные статусы. Нельзя выводить право доступа из того, что тело прошло схему. Нельзя считать revision: 4 доказательством, что обновление с ревизией 4 разрешено сейчас: это значение становится полезным только в договорённом протоколе проверки версии.

\n

Ограничение применимости здесь принципиальное: показанный валидатор не проверяет OpenAPI-документ, правила JSON Schema, права, базу, транзакцию, сетевой таймаут или полноту списка потребителей. Он предотвращает конкретные ошибки формы в учебной границе. Для выпуска нужны интеграционный маршрут, старый клиент и наблюдаемое правило удаления.

\n

Критерий готовности к выпуску

\n

Изменение ответа можно считать проверенным, когда команда показывает четыре независимых результата. Новая форма проходит схему и runtime-проверку на фактическом HTTP-ответе. Старый поддерживаемый клиент разбирает ответ и выполняет ожидаемую ветку. Отрицательные случаи имеют согласованные статусы и тела. Если diff несовместим, описаны версия или период совместимости и измеримое условие удаления.

\n

Если зелёный результат есть только у unit-теста валидатора, это ещё не совместимость API. Если схема не описывает смысл поля, добавьте поведенческий consumer-тест. Если неизвестны потребители, не обещайте безопасное удаление: сначала соберите сигнал использования или выберите версионирование. Такой критерий делает решение проверяемым и оставляет видимой цену неизвестности.

\n

Проверяемые источники

" + "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах видны неизвестное значение enum, отсутствие поля или вызов метода у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки — поиск версии клиента, срочный откат и проверка кэшей или данных, если изменение затронуло их формат.

\n

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

\n

Контракт начинается с границы

\n

В учебном примере граница — GET /customers/{id}, статус 200, media type application/json и тело ответа. Направление тоже входит в контракт: request отправляет клиент, response читает клиент. Поэтому обязательное поле в запросе и обязательное поле в ответе нельзя оценивать одним правилом.

\n

OpenAPI описывает HTTP-операцию, её ответы и доступную потребителю форму интерфейса. JSON Schema проверяет экземпляр JSON по типам, обязательным полям и ограничениям. Эти инструменты отвечают на разные части вопроса. Ни один из них сам по себе не доказывает, что фактический handler отдаёт описанное тело, что у пользователя есть право на ресурс или что ревизия записи ещё актуальна.

\n
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}
\n

HTTP 200 подтверждает успешную обработку запроса на уровне протокола, но не совместимость представления со старым кодом. Потребитель может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Синтаксически правильный JSON всё равно ломает клиент, если изменились тип, допустимые значения или смысл поля.

\n

Какие изменения ломают потребителя

\n
Направление изменения → риск → проверка → действие
ИзменениеЧто ломаетсяПроверкаДействие
response: поле удалили или переименовалиСтарый клиент обращается к propertyНайти чтения поля и старые fixturesСохранить поле на deprecated-период или выпустить версию
response: изменили тип или смыслДесериализатор или бизнес-ветка принимает неверное значениеПроверить тип и поведение старого клиентаДобавить новое поле с новым именем или сохранить семантику
response: добавили значение enumСтрогий decoder или ветка по умолчанию не знает значениеПрогнать старый код на каждом допустимом значенииНе включать значение в старый контракт или подготовить новую версию
request: добавили required-полеСтарый отправитель получает отказОтправить новую форму без поляСделать поле optional, дать default или изменить версию
response: добавили optional-полеОбычно ничего, но strict decoder может отклонить неизвестный ключПроверить реальную политику неизвестных полейЗафиксировать поведение decoder и добавить contract-test
\n

Слово breaking относится не к строке diff, а к конкретному потребителю и направлению обмена. Новое поле в response обычно расширяет контракт, если старый decoder игнорирует неизвестные ключи. Но схема с additionalProperties: false или строгая библиотека могут сделать такое расширение несовместимым. Решение принимают по исполняемому правилу клиента, а не по названию изменения.

\n

Учебный валидатор ответа

\n

Функция ниже получает уже разобранный JavaScript-объект. Она не ходит в сеть, не читает базу и не проверяет право доступа. Валидатор извлекает известные поля и возвращает ясную причину отказа. Дополнительные ключи он не использует и не объявляет допустимыми: политику strict или permissive нужно задать отдельной схемой и тестом.

\n
function 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-маршрута.

\n
\"Схема
Сначала проверяется форма ответа, затем клиент получает нормализованный объект. Красная ветка показывает отказ на неверном типе или значении; схема не заменяет проверку прав и состояния ресурса.
\n

Проверка до релиза

\n
  1. Зафиксируйте старый и новый контракт отдельно для request и response: метод, путь, статус, media type, обязательные поля, типы, nullable и enum.
  2. Соберите fixtures из фактического HTTP-ответа. Сохраните успешный случай, пропущенное поле, неверный тип и неизвестное enum-значение, а не только вручную созданный объект.
  3. Проверьте форму через OpenAPI или JSON Schema, затем прогоните runtime-проверку на ответе реального handler. Сверьте статус, заголовок Content-Type и тело.
  4. Найдите всех известных потребителей: чтения property, строгие decoders, DTO-мэпперы, сгенерированные SDK, кэши и события. Один найденный клиент не доказывает полноту списка.
  5. Запустите старую версию клиента против нового ответа. Для enum проверьте каждое значение, для optional-полей — поведение при неизвестном ключе, а для изменения типа — реальную десериализацию.
  6. Разведите ожидаемые ошибки. Зафиксируйте, что означает 400 для неверной формы, 403 для отказа в праве, 404 для отсутствующего ресурса, 409 для конфликта состояния и 412 для невыполненного условия If-Match.
  7. Выберите обратимый ход: сохранить старое поле, добавить новое рядом, открыть окно deprecated или выпустить новую версию. Для удаления запишите срок и наблюдаемый сигнал использования.
  8. После rollout сравните ошибки старого и нового клиентов, а затем удаляйте старую форму только после проверки этого сигнала. Не считайте зелёную сборку доказательством неизвестных внешних потребителей.
\n

Схема не видит состояние

\n

JSON Schema хорошо описывает документ: типы, обязательность, enum и дополнительные свойства. Она не видит пользователя, базу и время. Ответ с state: active может соответствовать схеме, хотя запись уже заблокирована. revision: 4 не доказывает, что обновление поверх ревизии 3 ещё разрешено.

\n

Состояние и право требуют отдельного протокола. Для авторизации сервис проверяет роль и возвращает согласованный отказ. Для конкурентного обновления он может использовать версию ресурса и условный запрос с If-Match; если условие не выполнено, клиенту нужен отдельный сигнал 412 Precondition Failed. Конфликт доменного состояния может быть 409 Conflict. Не маскируйте эти случаи под 400: форма запроса может быть правильной, а причина отказа — в праве или текущем состоянии.

\n

Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении или на редком кодовом пути. Поэтому contract-test должен вызвать маршрут и проверить фактические статус, заголовок и тело. Runtime-проверка снижает риск несовместимого payload, но не доказывает, что список потребителей полон.

\n

Ограничения и критерий готовности

\n

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

\n

Изменение готово к выпуску, когда команда может показать четыре доказательства:

\n\n

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

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/013.json b/editorial/agent-rewrites/013.json index 950e30b..d9a511b 100644 --- a/editorial/agent-rewrites/013.json +++ b/editorial/agent-rewrites/013.json @@ -2,6 +2,6 @@ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", - "excerpt": "Как связать security-требование, субъекта, объект и действие, а затем доказать на HTTP-границе, что чужой идентификатор не даёт доступ.", - "contentHtml": "

Пользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.

\n

Разберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.

\n

Почему успешный вход ничего не доказывает

\n

Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает id из URL, а затем делает поиск только по этому id, он может вернуть запись, которая субъекту не принадлежит.

\n

Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать GET /profiles/:id, но не вправе выбрать любой :id. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.

\n

Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.

\n

Требование превращается в проверяемую строку

\n

Фраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный user читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.

\n
Матрица входов для endpoint-а профиля
СубъектДействиеОбъектОжидаемый ответДоверенный источник
u-1, t-1readprofile u-1, t-1allow, 200session + database
u-1, t-1readprofile u-2, t-1deny, 403session + database
u-1, t-1readprofile u-3, t-2deny, 403session + database
нет valid credentialsreadprofile u-1, t-1deny, 401auth layer
u-1, t-1deleteprofile u-1, t-1deny, 403route policy
\n

Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует WWW-Authenticate; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.

\n

Где должен находиться контроль

\n

Нарисуйте путь данных до того, как писать условие. subjectId, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.

\n

Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. ownerId из JSON описывает желание клиента, но не доказывает владение.

\n
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.

\n
Цикл доказательства авторизации: требование связывается с субъектом и объектом, затем проходит отрицательный HTTP-тест и возвращается в разбор при расхождении.
Проверка замыкается только после сравнения ожидаемого и фактического HTTP-ответа. Unit-решение policy — промежуточное evidence, а не доказательство маршрута.
\n

Отрицательный тест должен быть исполняемым

\n

Happy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как authorization-policy.test.js и выполните командой node authorization-policy.test.js. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.

\n
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-а безопасный шаблон запроса выглядит так:

\n
curl -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. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.

\n

Проверяем не только policy

\n

Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Чужой профиль отвечает 200Проверили токен, но не объектu-1 запрашивает профиль u-2 напрямуюДобавить object-level deny и HTTP-тест
u-1 видит объект tenant t-2Tenant ограничили после общего чтенияПовторить запрос двумя tenant-амиВключить tenant в выборку и policy
Policy вернула deny, HTTP дал 200Adapter или handler потерял решениеСравнить policy result, статус и bodyСделать mapping явным и тестируемым
Чужой ответ появляется после прогрева кэшаКлюч не содержит permission contextПоменять субъекта после первого запросаРазделить ключ или отключить общий cache
403 раскрывает существование записиПубличный ответ повторяет внутреннюю причинуСравнить чужой и отсутствующий объектВыбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным
UI-тест зелёный, endpoint уязвимПроверяли только видимость кнопкиВызвать маршрут без браузераОставить HTTP-проверку в CI
\n

Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.

\n

Порядок проверки одного endpoint-а

\n
  1. Запишите маршрут, HTTP-метод, действие и тип объекта. Выберите тестовые identities, которыми разрешено пользоваться.
  2. Составьте матрицу: свой объект, чужой объект того же tenant-а, объект другого tenant-а, отсутствующая аутентификация и недопустимое действие.
  3. Назначьте источник каждого поля. Subject и роль приходят из auth context, owner и tenant — из серверного объекта, action — из маршрута.
  4. Проверьте запрос к хранилищу. Убедитесь, что tenant boundary не добавляется после выдачи данных и что объект не строится из client body.
  5. Напишите unit-тест policy с явным default deny. Отдельно проверьте реальный handler и сериализацию.
  6. Повторите все строки через HTTP без UI. Сохраните статус, безопасное тело, заголовки и идентификатор случая.
  7. Прогрейте кэш и повторите пары разными субъектами. Для mutation добавьте проверку отсутствия изменения после deny.
  8. Зафиксируйте расхождение как дефект policy, data access, mapping, cache или контракта. Не удаляйте отрицательный тест ради зелёного pipeline.
\n

Ограничения и критерий готовности

\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 AuthorizationFunction-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 для конкретного сервиса.
" + "excerpt": "К своему профилю API возвращает 200, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.", + "contentHtml": "

Представьте endpoint GET /profile?id=.... Пользователь u-1 запрашивает свой профиль и получает 200, затем меняет один идентификатор и получает профиль u-2 с тем же статусом. Интерфейс не показывает кнопку для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого.

\n

Это учебный сценарий, а не отчёт о конкретном инциденте. Его задача — показать, как превратить фразу «авторизация проверена» в воспроизводимое доказательство. Для одного endpoint мы свяжем требование, субъект, действие, объект, ожидаемый HTTP-ответ и фактический отрицательный тест.

\n

Сначала фиксируем контракт доступа

\n

Аутентификация отвечает на вопрос «кто пришёл». Авторизация отвечает на вопрос «может ли этот субъект выполнить действие над этим объектом». Валидная сессия не даёт доступа ко всем записям, а роль сама по себе не доказывает владение объектом.

\n

Возьмём требование AUTH-PROFILE-01: пользователь может прочитать свой профиль, но не профиль другого пользователя. В этой статье закрепим учебный HTTP-контракт. Для известного чужого профиля выберем 403, чтобы пример был однозначным; если продукт скрывает существование объекта, команда может выбрать 404, но тогда это значение нужно одинаково записать в policy, adapter и тест.

\n
Контракт учебного endpoint
СубъектДействиеОбъектОжидаемый ответ
user u-1readprofile u-1200, доступ разрешён
user u-1readprofile u-2403, доступ запрещён
guest u-1readprofile u-1403, роль не разрешена
без проверенного субъектаreadprofile u-1401, нужен challenge
admin a-1readaudit200, доступ разрешён
user u-1readнесуществующий объект404, объект не найден
\n

Статус — часть контракта, а не украшение отчёта. 401 относится к отсутствию действительного контекста аутентификации, 403 — к распознанному запросу без нужного разрешения. 404 означает отсутствие представления или сознательное сокрытие его существования. В тесте нельзя оставлять формулировку «403 или 404»: она не даёт команде проверяемого результата.

\n

Схема ниже показывает минимальную петлю доказательства: отрицательный вход должен дойти до assert, а расхождение возвращается в исправление требования или policy.

\n
\"Цикл
Проверяемый результат связывает требование с отрицательным входом; расхождение возвращает нас к policy и тесту.
\n

Идентификатор выбирает объект, но не даёт право

\n

Ссылка на объект может быть числом, UUID или slug. Непредсказуемый идентификатор полезен как дополнительная мера, но не заменяет проверку доступа. Если endpoint получает id из URL и сразу делает поиск по нему, пользователь может подставить соседнее значение.

\n
Откуда брать поля для решения
ПолеДоверенный источникОпасная подмена
subjectIdпроверенный контекст аутентификацииuserId из body или query
roleпроверенные claims и серверная политикароль из заголовка клиента
actionметод и маршрут endpointзначение из произвольного поля формы
ownerIdзапись ресурса и доменное хранилищеownerId, присланный клиентом
tenantIdконтекст субъекта и серверная записьtenant из URL без проверки принадлежности
\n

Сначала получите субъект из уже проверенного контекста. Затем загрузите ресурс в нужной области данных и определите его владельца на сервере. Поле ownerId из тела запроса не является доказательством владения: клиент может поменять его перед отправкой.

\n

Deny-by-default должен быть явным

\n

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

\n
function 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 и существовании чужой записи.

\n

В production policy должна применяться для каждого действия, которое принимает ссылку на объект: чтения, изменения, удаления, экспорта и административной операции. Если правило действует только в GET-handler, тот же объект может остаться доступным через PATCH или batch endpoint.

\n

Доказательство проходит через HTTP

\n

Unit-тест чистой функции полезен, но не ловит ошибку в middleware, загрузчике данных, сериализаторе или кэше. Следующий минимальный fixture запускает локальный HTTP-сервер, получает объект из серверной Map и проверяет реальный ответ. Заголовки x-subject и x-role здесь лишь заменяют проверенный контекст для примера; в настоящем сервисе клиент не должен сам определять эти значения.

\n
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 дополнительно проверьте тело ошибки, заголовки и отсутствие побочного действия, а не только число статуса.

\n

Где ломается зелёный тест

\n

Положительный unit-тест может быть зелёным, даже если реальный маршрут уязвим. Policy могла вернуть deny, а adapter — превратить его в 200. Репозиторий мог загрузить запись другого tenant до проверки. Кэш мог сохранить приватный ответ по ключу profile:42 и отдать его следующему субъекту.

\n

Проверка кэша — отдельный отрицательный сценарий: прогрейте ответ от 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-границу
После прогрева виден чужой ответКлюч кэша не разделяет контекстПовторить запрос разными субъектамиРазделить ключи или отключить кэш
\n

Порядок проверки

\n
  1. Выберите один endpoint, который принимает ссылку на объект, и присвойте требованию стабильный идентификатор.
  2. Запишите для него субъект, действие, объект и точный внешний ответ; не оставляйте «403 или 404».
  3. Проверьте источник каждого поля: subject и role из доверенного контекста, owner и tenant из серверной записи, action из маршрута.
  4. Создайте два изолированных тестовых субъекта и по одному объекту каждого. Не используйте реальные персональные данные.
  5. Добавьте allow для владельца и deny для чужого объекта, неподходящей роли, неизвестного объекта и недействительной аутентификации.
  6. Запустите policy-тест, затем прямой HTTP-тест без UI. Сохраните имя кейса, статус, безопасное тело ответа и отсутствие side effect.
  7. Повторите чтение после прогрева кэша и отдельно проверьте update, delete, export или batch, если они принимают тот же идентификатор.
\n

Ограничения и критерий готовности

\n

Fixture не проверяет настоящий токен, базу, reverse proxy, tenant isolation, CSRF, race condition или сетевую конфигурацию. Он доказывает только заявленный учебный маршрут. Случайный UUID не закрывает IDOR, а зелёный unit-тест не доказывает безопасность реального сервиса. Эти границы нужно назвать в отчёте, чтобы результат не расширился до необоснованного «авторизация безопасна».

\n

Проверку можно закрыть, когда есть версия требования, матрица входов, прямой HTTP-тест и фактический результат каждой строки. Владелец получает 200, чужой объект — закреплённый deny-ответ, анонимный запрос — 401 с challenge, неизвестный ресурс — 404. Повтор после кэша не меняет результат между субъектами, а клиент не видит внутреннюю причину policy.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/014.json b/editorial/agent-rewrites/014.json index c11ba14..63ff244 100644 --- a/editorial/agent-rewrites/014.json +++ b/editorial/agent-rewrites/014.json @@ -1,7 +1,7 @@ { "index": 14, "slug": "editorial-2027-08-mechanism-security-capstone", - "title": "Проверка логина не защищает объект: строим deny-by-default", - "excerpt": "Валидная сессия отвечает только на вопрос «кто отправил запрос». Разбираем объектную авторизацию, границу tenant-а и отрицательный путь от URL до ответа 403 или 404.", - "contentHtml": "

Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным, поэтому проверка логина проходит. Ошибка возникает дальше: сервер не проверяет, имеет ли этот субъект право читать выбранный объект. Это горизонтальная эскалация прав.

\n

Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, curl или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.

\n

Логин определяет субъекта, а не разрешение

\n

Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль user ещё не означают право читать любой ресурс с той же ролью.

\n

Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: GET /profile — чтение, PATCH /profile — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть tenantId, состояние записи, принадлежность команде или требуемый уровень чувствительности.

\n

Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится deny. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.

\n

Где проходит серверная граница

\n

Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный subjectId, роль и, если применимо, tenantId. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.

\n

Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре id + tenantId, а не сначала получать любой объект по одному id. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.

\n

Клиентский ownerId не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.

\n

Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Замена id в URL показывает чужой профильСессию проверили, владельца объекта — нетЗапросить свой и соседний идентификатор одной сессиейСверять subjectId с владельцем и областью объекта
Роль user читает audit endpointПроверка роли не связана с ресурсом и действиемВызвать маршрут напрямую, без интерфейсаДобавить отдельное allow-правило для audit:read
Неизвестная роль получает 200Ветка по умолчанию пропускает запросУдалить claim и повторить вызовВернуть отказ для любой неописанной комбинации
Чужой ответ появляется после кэшированияКлюч кэша не учитывает область или permission contextСравнить ответы для двух субъектовРазделить кэш или отключить его для персонального ответа
Ответ раскрывает наличие чужой записиВнешний статус и текст выбраны без модели угрозСравнить отсутствующий и запрещённый объектВыбрать 403 или маскирующий 404 и не раскрывать причину
\n
Матрица объектной авторизации связывает субъект, роль, действие, tenant и владельца записи с решением allow или deny
Идентификатор из URL выбирает объект, но не меняет его владельца и tenant. Решение принимается по серверным атрибутам.
\n

Самодостаточный учебный пример

\n

Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: Map заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.

\n
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-метода, схемы входа и сериализации.

\n

Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.

\n

Проверяйте отрицательный путь

\n

Успешное чтение собственного профиля доказывает только один allow-сценарий. Оно не показывает, что граница закрыта. Минимальный набор проверок должен содержать чужой объект в том же tenant-е, объект другого tenant-а, запрещённое действие, неизвестную роль, отсутствующий объект и запрос без обязательных учётных данных. Для mutation дополнительно проверяйте, что состояние не изменилось после отказа.

\n

Отправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым id. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.

\n

Причину отказа можно сохранить в безопасном журнале коротким кодом вроде owner-denied. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.

\n

Порядок внедрения

\n
  1. Выберите один endpoint и опишите его ресурс, метод, действие, субъект, tenant и изменяемые поля.
  2. Назовите доверенный источник каждого значения. Роль, владелец и область не должны приходить из пользовательского body.
  3. Загрузите объект в правильной области доступа; для tenant-системы включите tenant в условие репозитория.
  4. Определите явные allow-правила для каждой пары «ресурс + действие». Всё неизвестное оставьте deny.
  5. Разделите проверку credentials, объектную авторизацию, валидацию входа и сериализацию ответа.
  6. Добавьте allow-тест для своего объекта и deny-тесты для чужого объекта, другой области, запрещённого действия и неизвестной роли.
  7. Вызовите реальный HTTP-маршрут без UI и проверьте статус, тело, побочный эффект, кэш и заголовки.
  8. Зафиксируйте короткую причину решения и список непроверенных соседних маршрутов.
  9. Повторите отрицательные тесты после изменения middleware, репозитория, policy layer, схемы ответа или кэш-ключа.
\n

Ограничения и критерий готовности

\n

Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.

\n

Фильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».

\n

Endpoint можно считать проверенным только для заявленного контракта, если видны источник субъекта, правило области, серверный способ получения владельца, действие, внешний статус и доказательство отказа. Интеграционный тест должен дать 200 своему объекту, отказать чужому объекту и неизвестной роли через реальный маршрут. Если зелёным остаётся только unit-тест policy или только проверка UI, связь между запросом, хранилищем и ответом ещё не доказана.

\n

Проверяемые источники

\n" + "title": "Аутентификация не даёт доступ: строим deny-by-default для объекта", + "excerpt": "Валидная сессия не делает любой id разрешённым. Разбираем BOLA, доверенные поля, границу tenant-а и отрицательные HTTP-тесты от URL до ответа.", + "contentHtml": "

Пользователь вошёл в систему и запросил /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-by-default — явное решение для неизвестного

Политика должна возвращать отказ, если не совпала ни одна разрешённая комбинация. Неизвестная роль, действие, объект или область не должны попадать в «мягкую» ветку. На практике это означает список разрешений и последний результат deny, а не набор исключений, в котором легко забыть новый endpoint.

Политику вызывают на каждом бизнес-действии, а не только при отрисовке кнопки. Скрытый элемент интерфейса улучшает навигацию, но запрос можно отправить напрямую через HTTP-клиент. Middleware может подготовить субъект, handler — определить действие, репозиторий — вернуть запись, а слой политики — принять решение. Ни один из этих слоёв не должен подменять поля, которыми владеет другой.

Воспроизводимая проверка URL и объекта

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

401, 403 и 404 — часть контракта

Код ответа не заменяет policy, но помогает не смешивать границы. Отсутствующие или недействительные учётные данные относятся к 401. Сервер понял запрос, но не разрешил действие над известным объектом, — это кандидат на 403. Когда публикация самого факта существования записи опасна, сервис может вернуть 404 и для запрещённого объекта. Выбор должен быть единым для endpoint-а и модели угроз, а не случайной реакцией разных обработчиков.

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

Проверяем отрицательный путь без UI

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

Матрица отрицательных проверок
ВходОжидаемое решениеЧто подтверждает тест
user u-1 → profile u-1, readallow / 200Легитимный сценарий не сломан
user u-1 → profile u-2, readdeny, внешний 403 или 404Подмена id не даёт чужой объект
user u-1 → profile u-3, другой tenantdeny, без данных записиОбласть участвует в решении
guest u-1 → profile u-1denyНеизвестная роль не получает доступ
user u-1 → audit или запрещённый методdeny до бизнес-операцииРоль и действие не подменяются URL

Запускайте такие проверки через настоящий маршрут и через прямой HTTP-клиент, без кликов в браузере. Сравнивайте код, тело и набор полей ответа для разрешённого и запрещённого случаев. Отдельно проверьте прокси и кэш: персональный ответ не должен попасть под общим ключом к следующему субъекту.

Порядок внедрения

  1. Выберите один endpoint и запишите его субъект, метод, действие, идентификатор объекта и внешний ответ.
  2. Для каждого поля укажите доверенный источник: сессия, claims, маршрут или серверная запись.
  3. Загрузите объект в нужной области. Для tenant-системы включите tenant в запрос к репозиторию.
  4. Опишите явные allow-правила для роли, действия и объекта. Последним результатом оставьте deny.
  5. Не принимайте владельца, tenant и роль из тела запроса, параметров URL или клиентского заголовка.
  6. Сначала добавьте тест своего объекта, затем чужого объекта, другой области, неизвестной роли и запрещённого метода.
  7. Согласуйте внешний контракт 401/403/404 и не возвращайте внутреннюю причину отказа без необходимости.
  8. Проверьте сериализацию, кэш, журнал и повторный вызов после изменений middleware, репозитория или policy.

Ограничения и критерий готовности

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

Для одного endpoint-а работа готова, когда интеграционный тест через реальный маршрут разрешает свой объект, отказывает чужому и неизвестной роли, проверяет другую область и сравнивает тело ответа. У запретной ветки нет приватных полей, а решение не зависит от видимости UI-кнопки. Следующий шаг — взять один endpoint в окружении, похожем на production, записать матрицу «субъект × действие × объект» и сохранить эти отрицательные случаи как регрессионные тесты.

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/015.json b/editorial/agent-rewrites/015.json index 7c257b5..e3a0025 100644 --- a/editorial/agent-rewrites/015.json +++ b/editorial/agent-rewrites/015.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-08-practice-security-capstone", "title": "SSRF начинается с URL: проверяем адрес до сетевого вызова", "excerpt": "Практическая защита server-side запроса: разбираем URL, применяем точный allowlist, запрещаем обход через credentials и редиректы, а затем ограничиваем сам сетевой вызов.", - "contentHtml": "

Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» 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
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.

\n

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

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
function validateRemoteUrl(value, allowedHosts) {\n  let url;\n  try {\n    url = new URL(value);\n  } catch {\n    return { allowed: false, reason: 'invalid-url' };\n  }\n\n  if (url.protocol !== 'https:') {\n    return { allowed: false, reason: 'scheme' };\n  }\n  if (url.username || url.password) {\n    return { allowed: false, reason: 'credentials' };\n  }\n  if (url.port && url.port !== '443') {\n    return { allowed: false, reason: 'port' };\n  }\n  if (!allowedHosts.includes(url.hostname)) {\n    return { allowed: false, reason: 'host' };\n  }\n\n  return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n  'https://cdn.example.test/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n  'https://cdn.example.test@127.0.0.1/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }
\n
const 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 с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA, если модель угроз требует контроля каждого адреса.

\n

В чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.

\n

DNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

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

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Он также не ограничивает путь запроса, размер тела или число редиректов. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" + "contentHtml": "

Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Проверка префикса https://cdn.example.test видит доверенное начало и пропускает запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Защита начинается до сетевого вызова: парсер строит структуру URL, политика проверяет схему, credentials, hostname и порт, а сетевой слой ограничивает фактический egress. Учебный валидатор ниже возвращает решение без DNS и HTTP. Далее отдельно разберём redirect, адреса A/AAAA и лимиты ответа, которые нельзя спрятать за одной функцией.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» 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
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Значит, правило должно описывать владение именами, а не похожесть строки. WHATWG URL Standard отдельно различает example.test и example.test.; не удаляйте завершающую точку молча: выберите каноническую форму конфигурации и закрепите её тестом.

\n

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

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку URL и массив разрешённых имён. Сначала new URL строит разобранный URL, затем конфигурация один раз приводится к нижнему регистру и сравнивается с url.hostname. Результат имеет форму { allowed, reason, href }. Код не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy; локальные кейсы ниже проверяют порядок решений, но не доказывают безопасность DNS, proxy или реальной сети. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
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. Неверная схема, нестандартный порт и невалидная строка получают отдельные причины.

\n

Это регрессионный тест парсера и политики, а не сетевой тест. allowed: true означает только, что разобранные поля совпали с конфигурацией. Перед передачей href клиенту задайте запрет автоматических redirect, timeout и лимит тела; DNS и egress проверяйте на отдельном слое. Полный входной URL не включайте в лог отказа: в нём могут быть пароль, token или query с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному 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-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

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

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/016.json b/editorial/agent-rewrites/016.json index 9bb5b94..c479014 100644 --- a/editorial/agent-rewrites/016.json +++ b/editorial/agent-rewrites/016.json @@ -2,6 +2,6 @@ "index": 16, "slug": "editorial-2027-07-field-reliability-capstone", "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", - "excerpt": "Разбираем лавину повторных запросов при сбое зависимости: какие поля сохранить, когда остановиться и почему timeout записи нельзя считать доказательством неуспеха.", - "contentHtml": "

Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.

\n

Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.

\n

Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принять решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.

\n

Механизм отказа

\n

Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать: requestId помогает найти один сетевой вызов, а operationId связывает весь пользовательский сценарий. Если записать только финальное «503», расследование потеряет порядок событий.

\n

Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded; когда достигнут лимит попыток, это другое состояние.

\n

Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, check_state, attempts_exhausted и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.

\n
Минимальный контракт события попытки
ПолеПримерЗачем
operationIdop-42Связать попытки одной операции
requestIdreq-02Найти одну сетевую попытку
attempt2Увидеть порядок и число вызовов
methodGETПроверить семантику повтора
status или errorClass503, timeoutОтделить ответ сервера от исключения
remainingMs180Понять, сколько времени оставалось
decisionretryЗафиксировать решение клиента
\n
\"Цикл
Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.
\n

Что именно можно повторять

\n

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

\n

GET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и учитывает Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия, проверка параметров и сохранённый результат.

\n

Статус сам по себе тоже не даёт разрешения на retry. 503 обычно означает временную недоступность, но ответ посредника мог появиться после того, как upstream уже применил запись. 429 требует учесть ограничение сервера, а ошибка DNS, отмена пользователем и ошибка валидации не должны попадать в один список с временным отказом.

\n

Воспроизводимый пример

\n

Учебный пример ниже намеренно консервативен. Он не обращается в сеть, а получает заранее заданный массив ответов. Это позволяет воспроизвести решение клиента и отдельно увидеть разницу между последней попыткой и исчерпанным deadline. Пример повторяет только GET со статусом 503; он не доказывает поведение конкретной HTTP-библиотеки.

\n
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 и не создаст второй вызов.

\n

Проверки console.assert фиксируют итог и порядок решений. Они не заменяют тест реального клиента: в production нужно измерять монотонное время, обрабатывать сетевые исключения, учитывать Retry-After, добавлять backoff с jitter и передавать отдельный requestId для каждой попытки.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Резкий рост запросов после 503Нет общего deadline или backoffСравнить attempt и remainingMsОграничить бюджет, добавить задержку и jitter
Один пользователь получил две записиPOST повторили после timeoutСопоставить operationId на сервереОстановить retry, ввести ключ и запрос состояния
В логах только «failed»Причина и decision слитыНайти status/errorClass и decisionСделать перечисление причин и действий
Клиент ждёт дольше SLATimeout задан на попытку, не на операциюПроверить остаток времени перед вызовомПередавать общий deadline вниз по стеку
После 429 нагрузка не падаетКлиент игнорирует ограничение сервераПроверить Retry-After и частоту попытокСнизить темп и завершать попытку по политике лимита
Нельзя связать клиентский и серверный следИдентификатор меняется при retryСопоставить operationId и requestIdСохранить идентификатор операции, запросу дать новый номер
\n

Неизвестный результат записи

\n

Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.

\n

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

\n

Логи помогают расследованию, но не делают повтор безопасным. Записывайте метод, endpoint без секретных параметров, обезличенный ключ операции, requestId, номер попытки, статус, класс ошибки, остаток времени и решение. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины.

\n

Порядок внедрения

\n
  1. Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.
  2. Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.
  3. Разделить timeout отдельного вызова и общий deadline операции.
  4. Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток, backoff и действие при исчерпании времени.
  5. Добавить событие попытки с requestId, attempt, status или errorClass, remainingMs и decision.
  6. Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.
  7. Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.
  8. Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.
  9. Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.
\n

Ограничения

\n

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

\n

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

\n

Учебная функция не моделирует два процесса, атомарность базы, частичную запись, истечение TTL ключа или повтор после восстановления. Поэтому она годится для проверки ветвления и названий состояний, но не для обещаний о доступности или времени ответа. Производственные числа получают из наблюдений конкретной системы.

\n

Проверяемый критерий готовности

\n

Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после временных отказов видны попытки с decision=retry, а затем успешный возврат, attempts_exhausted или честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.

\n

Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.

\n

Проверяемые источники

" + "excerpt": "Собираем контракт событий попытки: как связать operationId и requestId, отделить timeout от ответа и остановиться по deadline без слепого повтора.", + "contentHtml": "

В журнале часто остаётся только «request failed». По такой записи нельзя понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Команда видит шум, но не видит последовательность, которая его создала.

\n

Цена ошибки — лишняя нагрузка для чтения и повторный побочный эффект для записи: второй заказ, два письма или повторное списание. Timeout добавляет неопределённость: сервер мог не получить запрос, мог его отклонить или уже применить, пока ответ потерялся. Поэтому строка «повторить при ошибке» недостаточна.

\n

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

\n

Событие должно описывать одну попытку

\n

Одна пользовательская операция может породить несколько HTTP-запросов. operationId создаётся на границе операции и остаётся прежним. requestId относится к одной сетевой попытке и меняется при retry. Поле attempt показывает порядок. Если генерировать все три идентификатора внутри низкоуровневого клиента, расследование потеряет связь между повторениями.

\n

Ниже — минимальный локальный контракт. Его имена не являются готовыми семантическими соглашениями OpenTelemetry: команда должна согласовать типы, срок хранения, доступ и правила очистки отдельно. Важно сохранить смысл полей: ответ сервера не смешивается с исключением клиента, а причина не смешивается с действием.

\n
Минимальный контракт события попытки
ПолеПримерЧто проверяет
operationIdop-42Все попытки относятся к одной операции
requestIdop-42/attempt-2Конкретный сетевой вызов и его след
attempt2Порядок и фактическое число вызовов
methodGETПрименимость политики повтора
status503 или nullОтвет получен или его нет
errorClasstimeout или nullКласс ошибки транспорта или клиента
elapsedMs80Сколько заняла попытка
remainingMs280Сколько общего бюджета было до вызова
decisionretryКакое действие выбрал клиент
\n
\"Цикл
Попытка оставляет проверяемое событие до следующего вызова. Новый requestId не разрывает operationId, а неизвестный результат записи ведёт к проверке состояния.
\n

Не смешиваем ответ, timeout и решение

\n

503 — это полученный HTTP-ответ. Он сообщает о недоступности сервиса в момент запроса, но не выбирает политику конкретного клиента. timeout — отсутствие ответа в отведённое время. Это не доказательство, что сервер не выполнил операцию. decision=retry — третье измерение: это уже решение вызывающей стороны, а не свойство ответа.

\n

Для HTTP/3 транспорт может сообщить о состоянии соединения или потока, но прикладной протокол отдельно определяет смысл данных и ошибок. Поэтому закрытый поток не превращается автоматически в «заказ не создан». В событии нужно сохранить границу знания: что увидел клиент и какой результат остался неизвестным.

\n
Что известно после разных исходов
НаблюдениеЧто известноЧего нельзя утверждатьСледующее действие
200 после GETОтвет получен, попытка завершиласьЧто следующая попытка тоже нужнаВернуть ответ и закрыть операцию
503 после GETСервис ответил временной недоступностьюЧто повтор безопасен для любого методаПроверить метод, бюджет и лимит попыток
timeout после GETКлиент не получил ответ вовремяЧто запрос не был принят серверомРассмотреть ограниченный повтор чтения
timeout после POSTРезультат прикладной записи неизвестенЧто повтор создаст только одну записьПроверить состояние по ключу операции
Deadline исчерпан до вызоваНовая попытка не начиналасьЧто зависимость получила этот вызовВернуть terminal reason без сетевого запроса
\n

Для записи безопасное действие после timeout — не новый POST, а запрос состояния по согласованному ключу или ручное разбирательство. Такой переход можно назвать check_state. Он не утверждает успех или неуспех: он сохраняет неизвестный результат до отдельной проверки.

\n

Учебный исполнитель с фиксированным timeline

\n

Пример ниже не открывает сеть. Массив observations заранее задаёт ответы и длительность, поэтому любой инженер может повторить последовательность событий на одной машине. Время здесь виртуальное: фиксированная задержка backoffMs нужна для демонстрации бюджета, а не является настройкой production-клиента.

\n
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, поэтому функция не запускает второй сетевой вызов.

\n

Это узкая проверка порядка. В ней нет DNS, пула соединений, реального clock, конкурентных клиентов, server-side дедупликации и доставки событий. Именно поэтому результат примера нельзя называть измерением доступности или доказательством безопасности конкретного API.

\n

Deadline — часть события, а не настройка цикла

\n

Локальный timeout ограничивает одну попытку, а deadline ограничивает всю операцию. Если каждая из трёх попыток получает по 400 миллисекунд, вызывающий код может ждать больше секунды. Правильная схема передаёт вниз один общий момент окончания и вычисляет remainingMs перед каждым вызовом:

\n

remainingMs = deadlineMonotonic - monotonicNow

\n

Время бюджета измеряют монотонными часами. Wall-clock пригоден для корреляции записей между узлами, но скачок системных часов не должен внезапно разрешить ещё одну сетевую попытку. В событии полезно хранить и elapsedMs, и остаток до вызова: одно показывает стоимость действия, другое — доступный запас.

\n

Заголовок Retry-After нужно сохранять отдельным полем, например retryAfterMs. Сервер может подсказать задержку после 503, но клиент всё равно ограничивает её своим deadline, лимитом попыток и политикой метода. Подсказка о времени ожидания не превращает небезопасный POST в идемпотентную операцию.

\n

maxAttempts и deadline отвечают на разные вопросы. Первый ограничивает количество вызовов, второй — время всей операции. Поэтому в терминальном событии стоит различать max_attempts и deadline_exceeded. Иначе команда начнёт увеличивать число попыток, когда на самом деле зависимость отвечает слишком медленно.

\n

Как читать последовательность событий

\n

В нормальном расследовании сначала группируем записи по operationId, затем сортируем по attempt или времени начала. Внутри одной группы requestId должен быть уникальным для попытки. Если встречаются два события с одним requestId, проверяем повторную доставку логов; если меняется operationId, проверяем место создания идентификатора.

\n

Последовательность 503 → 503 → 200 означает три наблюдаемых ответа, но не «сервис был полностью недоступен». Она подтверждает только ответы конкретной операции. Последовательность 503 → deadline_exceeded означает, что клиент остановился после первой попытки; это не новый ответ зависимости.

\n

Отдельно считаем решения. Доля retry показывает поведение клиента, а доля timeout — наблюдаемый класс отказа. Не складывайте их в одну метрику ошибок: один timeout может привести к check_state, а один 503 — к безопасному ограниченному retry. Разные причины требуют разных действий.

\n

Для наблюдаемости полезно разделить сигналы. Trace связывает путь запроса, log хранит конкретное событие, metric показывает распределение попыток и долю завершений по deadline. operationId и requestId не стоит бездумно превращать в labels метрики: высокая кардинальность сделает график дорогим и малоинформативным.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для retry-событий
СимптомПричинаПроверкаДействие
Везде только 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 и удалить секреты
\n

Порядок внедрения

\n
  1. Назовите операцию. Зафиксируйте доменное действие, побочный эффект и владельца решения. HTTP-метод остаётся подсказкой, а не полной политикой.
  2. Разделите идентификаторы. Создайте устойчивый operationId до первого вызова, выдавайте новый requestId каждой попытке и увеличивайте attempt.
  3. Опишите конечные значения. Заранее перечислите допустимые status, errorClass, decision и terminal reason. Не заменяйте их свободным сообщением.
  4. Задайте общий бюджет. Передавайте deadline вниз по стеку, отдельно ограничьте timeout вызова и включите backoff в тот же бюджет.
  5. Сохраняйте событие. Записывайте статус или класс ошибки, длительность, остаток времени и решение. В одном событии не должно быть одновременно фиктивного статуса и придуманной причины.
  6. Проверьте неизвестный результат. Для timeout записи используйте ключ операции и read-state endpoint либо ручной маршрут. Новый POST без такой проверки запрещён.
  7. Очистите данные. Уберите query-секреты, cookie, токены, тело ответа и персональные идентификаторы до отправки log или trace.
  8. Прогоните отрицательные пути. Проверьте успешный первый вызов, два 503 и 200, исчерпание deadline, timeout GET и timeout POST. Проверяйте события и число реальных вызовов.
  9. Считайте последствия. Отдельно наблюдайте распределение попыток, долю timeout, terminal reason и нагрузку зависимости. Не меняйте политику по одной записи.
\n

Ограничения

\n

Фикстура использует заранее записанный timeline и не моделирует сетевую очередь, балансировщик, потерю логов, рестарт процесса, конкуренцию и реальные серверные побочные эффекты. В production время нужно брать из монотонного clock, а задержку — из согласованной политики зависимости. Фиксированные 40 миллисекунд в коде не являются универсальным backoff.

\n

RFC описывает свойства HTTP-методов и транспортные границы, но не знает доменный эффект вашего endpoint. Даже идемпотентный метод может иметь неожиданные побочные действия из-за реализации. И наоборот, POST может получить отдельный idempotency-key контракт, но его срок, область уникальности, проверку тела и хранение результата должен доказать сервер.

\n

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

\n

Проверяемый критерий готовности

\n

Изменение готово, если фиксированный набор входов даёт одинаковую последовательность событий при повторном запуске. Для чтения 503 → 503 → 200 должны сохраниться один operationId, три разных requestId, номера попыток 1–3, остаток 400 → 280 → 160 и решения retry → retry → return. Итогом должен быть полученный 200, а не синтетический успех после исчерпания бюджета.

\n

Для timeout GET проверяем ограничение числа вызовов и отдельный terminal reason. Для timeout POST проверяем один побочный вызов, решение check_state и отсутствие второго POST. В каждом событии есть либо status, либо errorClass, есть remainingMs и decision, а секреты не попадают в log или trace.

\n

Этот критерий не обещает доступность зависимости и не доказывает корректность её транзакции. Он проверяет границу, которой управляет клиент: ограниченный retry не превращается в лавину, а неизвестный результат записи не превращается в новый побочный эффект.

\n

Проверяемые источники

" }