diff --git a/editorial/agent-rewrites/173.json b/editorial/agent-rewrites/173.json index 2a54279..57eee33 100644 --- a/editorial/agent-rewrites/173.json +++ b/editorial/agent-rewrites/173.json @@ -1,7 +1,7 @@ { "index": 173, "slug": "editorial-2023-03-mechanism-csrf-cors", - "title": "CSRF и CORS: как разделить доступ к ответу и право изменить данные", - "excerpt": "CORS решает, может ли чужой origin прочитать ответ. CSRF проверяет, разрешено ли cookie-аутентифицированному запросу менять состояние. Разбираем origin, credentials, preflight и отрицательные проверки на одном endpoint.", - "contentHtml": "

После переноса frontend на новый домен приложение начинает показывать CORS error. Одни запросы не видны JavaScript, другие получают 403, а часть операций, судя по логам, всё же доходит до API. Ошибка провоцирует опасную правку: разрешить *, принять любой origin или отключить CSRF middleware. Цена — не только сломанный интерфейс. Сервер может открыть ответ лишнему сайту или принять изменение от страницы, которая не выражала доверенный intent.

\n

Главный тезис простой: CORS и CSRF работают на разных границах. CORS ограничивает доступ браузерного кода к cross-origin response. CSRF защищает state-changing запрос, который использует учетные данные пользователя, например cookie сессии. Preflight проверяет форму запроса. Он не подтверждает token, пользователя или право на объект.

\n

Сначала зафиксируйте наблюдаемый симптом

\n

Не начинайте с заголовка, которого не хватает. Возьмите одну операцию и запишите пять значений: полный origin страницы, URL API, method, content type и имена request headers. Добавьте статус ответа и те response headers, которые видны в DevTools или на proxy boundary. Сообщение в консоли полезно как сигнал, но не объясняет, был ли отправлен actual request, скрыл ли браузер ответ или сервер отклонил mutation.

\n

Например, страница находится на https://app.example.test, а API — на https://api.example.test. Клиент вызывает fetch() с credentials: 'include' и отправляет X-CSRF-Token. Если OPTIONS не разрешает method или header, actual request может не начаться. Если preflight прошел, это ещё не значит, что token совпал с сессией. Если token совпал, пользователь всё ещё может не иметь права на конкретную запись.

\n

Механизм: три независимых слоя

\n

Origin. Браузер сравнивает tuple из scheme, host и port. https://app.example.test и http://app.example.test имеют разные origins. Порт 8443 тоже меняет tuple. Path, query и fragment в origin не входят. Поэтому проверка вида host.endsWith('example.test') слишком широка: она превращает любой поддомен в доверенный источник.

\n

Origin — это техническая граница браузера, а не готовая модель бизнес-доверия. Даже точный https://admin.example.test не должен автоматически получать права user API. Его добавляют в allow-list только для конкретной причины, endpoint и набора данных.

\n

CORS. Сервер сообщает браузеру, какому source origin можно отдать response браузерному коду. Для credentialed response нужен точный Access-Control-Allow-Origin и Access-Control-Allow-Credentials: true. Значение * нельзя совмещать с запросом, который использует credentials. Это правило отвечает за видимость представления ответа. Оно не авторизует mutation и не проверяет CSRF token.

\n

CSRF. Cookie отправляется браузером автоматически по своим правилам. Сам факт наличия cookie не доказывает, что пользователь намеренно вызвал действие из доверенного интерфейса. Сервер должен проверить token, строгую origin policy или другой подходящий proof до побочного эффекта. После этого он отдельно проверяет authorization: имеет ли actor право выполнить действие над данным объектом.

\n

Preflight. Браузер отправляет OPTIONS, когда форма cross-origin запроса выходит за CORS safelist. PATCH, JSON content type и custom header часто приводят к preflight. Успешный OPTIONS разрешает форму следующего запроса для указанного origin. Он не сравнивает CSRF token с сессией и не проверяет бизнес-права. Обычный form-shaped POST может не иметь preflight, хотя меняет состояние. Поэтому CSRF нельзя строить на предположении, что опасный запрос обязательно виден как OPTIONS.

\n
\"Три
Схема помогает разделить tuple origin, доступ к response и серверную проверку state-changing запроса. Это учебная иллюстрация, а не трасса браузера и не доказательство доставки cookie.
\n

Конкретный пример

\n

Рассмотрим cookie-аутентифицированный endpoint PATCH /profile. Клиент живет на разрешенном origin и передает token в custom header. Учебный сервер сначала проверяет origin и token, затем право пользователя. Код показывает порядок условий, но не заменяет middleware, браузерный тест и проверку cookie attributes.

\n
const trustedOrigins = new Set([\n  'https://app.example.test',\n]);\n\nfunction updateProfile(request, session) {\n  const origin = request.headers.get('Origin');\n  const token = request.headers.get('X-CSRF-Token');\n\n  if (!trustedOrigins.has(origin)) {\n    return deny(403, 'untrusted-origin');\n  }\n  if (!token || !constantTimeEqual(token, session.csrfToken)) {\n    return deny(403, 'csrf-failed');\n  }\n  if (!session.user.can('profile:update')) {\n    return deny(403, 'not-authorized');\n  }\n\n  return applyProfileChange(request.body);\n}
\n

В этом примере constantTimeEqual, выдача token, rotation, logout и обработка ошибок должны существовать в реальном компоненте безопасности. Фрагмент намеренно учебный. Он показывает, что CORS response и CSRF proof находятся рядом, но не являются одной проверкой. Он также сохраняет отрицательный путь: неверный origin, отсутствующий token и отсутствие permission не должны доходить до applyProfileChange.

\n

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

\n
СимптомПричинаПроверкаДействие
JavaScript не читает responseOrigin не разрешен или заголовок не совпалСверить полный scheme, host и port с exact Access-Control-Allow-OriginДобавить только нужный origin и проверить cache policy
Credentials response заблокированИспользован wildcard или отсутствует credentials headerСопоставить credentials: 'include' с двумя response headersВернуть exact origin и Access-Control-Allow-Credentials: true
OPTIONS получает 403Не разрешены method или custom headerСверить Access-Control-Request-Method и Access-Control-Request-HeadersРазрешить узкий фактический набор, не все методы и headers
POST form получает 403CSRF proof отсутствует или не совпалПосмотреть server reason до mutation и проверить token flowИсправить выдачу и передачу token; CSRF не отключать
UI видит CORS error, а запись измениласьСервер выполнил action, но браузер скрыл responseСопоставить server log, status и browser network evidenceИсправить CORS visibility и отдельно сохранить CSRF/authorization
Token принят, но ответ 403Не пройдена бизнес-авторизацияРазделить причины CSRF и permission в server logИсправить policy ресурса, а не расширять CORS
\n

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

\n
  1. Зафиксируйте failing operation и цену ошибки: данные не читаются, mutation отклоняется или action мог выполниться без видимого response.
  2. Составьте точный request contract: source origin, target URL, method, content type, credentials mode и header names. Не заменяйте эти значения фразой «запрос с фронта».
  3. Разделите этапы. Отдельно проверьте exact CORS response, отдельно OPTIONS, отдельно server-side CSRF proof и отдельно authorization.
  4. Добавьте отрицательные проверки: другой scheme, другой port, незнакомый subdomain, отсутствующий token и token от другой сессии. Для каждого варианта ожидайте отказ до side effect.
  5. Проверьте реальный browser flow в тестовой среде с безопасной test session. Сопоставьте DevTools или HAR с proxy/application status. Не переносите production cookie, token и пользовательские данные в фикстуру.
  6. После исправления повторите исходный happy path и тот же отрицательный path. Успешный CORS response не отменяет CSRF assertion, а успешный token test не доказывает, что frontend прочитает ответ.
  7. Закрепите узкое правило тестом и конфигурацией с понятным owner. Новый frontend origin должен проходить отдельный security review, а не появляться копированием существующей строки.
\n

Ограничения

\n

Атрибуты cookie, SameSite policy, third-party cookie restrictions, redirects, proxy cache и режим приватности браузера влияют на фактическую доставку credentials. Поэтому нельзя заключить из одного response header, что cookie была отправлена. Нельзя и заключить из отсутствия OPTIONS, что запрос безопасен: form submission и некоторые safelisted shapes способны менять состояние.

\n

CSRF не защищает от XSS на уже доверенном origin. Скрипт, который получил выполнение в приложении, может использовать доступные ему API и token. CSRF также не заменяет authorization, rate limiting, audit log, CSP или контроль webhook и service-to-service клиентов. Для каждого caller нужен явный authentication contract.

\n

Учебный код ограничен. Он не реализует Fetch, не моделирует браузер, не проверяет все byte-level ограничения заголовков, не учитывает CORS cache и не подтверждает конкретную версию framework. Реальный результат появляется только из browser/integration проверки в контролируемой среде и server evidence.

\n

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

\n

Endpoint готов к изменению, если reviewer может назвать разрешенный source origin, увидеть exact credentialed CORS contract, воспроизвести нужный preflight или доказать его отсутствие, получить отказ без CSRF proof и отдельно подтвердить permission check. В тестовой среде server log показывает, что отрицательные ветки не вызвали side effect. Если есть только CORS header, работа не готова. Если есть только unit test token, не доказана интеграция с браузером. Если есть только ручной happy path, не защищен отрицательный путь.

\n

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

\n" + "title": "CSRF и CORS: где заканчивается доступ к ответу и начинается защита действия", + "excerpt": "Разбираем на одном cookie-аутентифицированном endpoint, чем отличаются origin, CORS, preflight и CSRF-проверка, как воспроизвести запрос через curl и где заканчиваются гарантии каждого слоя.", + "contentHtml": "

После переноса интерфейса на новый origin команда видит в консоли CORS error. Один запрос «не читается» из JavaScript, другой получает 403, а третья операция, судя по журналу API, всё-таки меняет данные. Эти симптомы легко свести к одной настройке и открыть Access-Control-Allow-Origin: * или отключить CSRF-проверку. Так можно одновременно разрешить лишнему сайту читать ответы и пропустить изменение, отправленное браузером с чужой страницы.

\n

Правильная диагностика начинается с разделения вопросов. Origin определяет контекст страницы. CORS сообщает браузеру, можно ли коду этой страницы прочитать cross-origin response. Preflight проверяет, разрешена ли форма будущего запроса. CSRF-защита решает на сервере, есть ли у state-changing запроса доказательство доверенного намерения. Ни один из этих слоёв не заменяет authentication и authorization.

\n

Симптом сначала, заголовок потом

\n

Сообщение браузера не отвечает на главный вопрос: запрос не ушёл, ушёл и был отклонён сервером или сервер выполнил действие, но браузер скрыл ответ от JavaScript. Для одного проблемного endpoint зафиксируйте URL страницы, URL API, method, content type, режим credentials, request headers, status и наличие побочного эффекта. Проверяйте одну и ту же тестовую сессию, иначе смена cookie создаст ложную причину.

\n

Пусть страница открыта с https://app.example.test, а API находится на https://api.example.test. Клиент отправляет PATCH /profile с credentials: 'include', JSON и заголовком X-CSRF-Token. Для браузера это cross-origin запрос: host отличается, хотя оба имени находятся в одном условном домене. Если сервер не разрешил method или заголовок в preflight, actual request обычно не начнётся. Если preflight прошёл, token ещё не проверен. Если token прошёл, право менять конкретный профиль всё ещё нужно проверять отдельно.

\n

Origin — не домен и не право доступа

\n

В модели Web Origin Concept origin — это комбинация scheme, host и port. Поэтому https://app.example.test, http://app.example.test и https://app.example.test:8443 — разные origins. Path, query и fragment в origin не входят: https://app.example.test/a и https://app.example.test/b имеют один origin.

\n

Из этого не следует, что любой поддомен безопасен. Проверка вроде host.endsWith('.example.test') расширяет доверие на каждый хост под доменом, включая забытый в DNS или отданный внешнему провайдеру. Для credentialed API храните явный allow-list полных origin и сравнивайте сериализованное значение целиком. https://admin.example.test может быть разрешённым источником для одного ресурса, но это не даёт его коду бизнес-права на любой объект.

\n
\"Схема
Одна cross-origin операция проходит несколько независимых вопросов. Стрелки на схеме не означают автоматическую передачу credentials: cookie и token подчиняются собственным правилам.
\n

CORS управляет чтением ответа

\n

CORS — протокол обмена между user agent и сервером. Сервер возвращает Access-Control-Allow-Origin, а браузер использует его при решении, можно ли отдать response коду страницы. Это не сетевой firewall: серверный endpoint может получить простой cross-origin запрос даже тогда, когда JavaScript не сможет прочитать ответ. Поэтому CORS-заголовок нельзя использовать как единственную защиту операции.

\n

Для запроса с credentials: 'include' сервер должен вернуть точный origin, например Access-Control-Allow-Origin: https://app.example.test, и Access-Control-Allow-Credentials: true. Пара * плюс true не разрешает credentialed response: Fetch Standard прямо считает такой вариант недопустимым. Если origin выбирается динамически из allow-list, добавьте Vary: Origin, чтобы промежуточный cache не отдал ответ, подготовленный для другого источника.

\n

Важна и обратная сторона: response с ошибкой тоже может быть скрыт от JavaScript. Поэтому «в консоли CORS error» не доказывает, что сервер не выполнил side effect. Сверяйте browser Network, status на API boundary и запись в журнале операции.

\n

Preflight проверяет форму, а не намерение

\n

Браузер делает preflight, когда запрос не укладывается в CORS safelist. Типичные причины — method PATCH, JSON content type или custom header. Preflight — это OPTIONS с Origin, Access-Control-Request-Method и, при необходимости, Access-Control-Request-Headers. Сервер отвечает разрешёнными method и header, после чего браузер может отправить actual request.

\n

Preflight не содержит cookie сессии: в Fetch Standard credentials mode для него — same-origin. Он не сравнивает CSRF token, не знает пользователя и не проверяет право на профиль. Более того, обычная HTML-форма может отправить простой POST без preflight. Значит, отсутствие строки OPTIONS в логах не означает отсутствие CSRF-риска.

\n

Практический контракт для нашего endpoint выглядит узко: разрешить только https://app.example.test, method PATCH и headers content-type, x-csrf-token. Не следует отвечать «разрешаю всё», если клиенту нужен один method и один заголовок.

\n

Рабочий repro через curl

\n

Сначала воспроизведите preflight без реальной cookie. Команда проверяет только CORS-контракт; она не доказывает, что actual request будет принят.

\n
curl -i -X OPTIONS https://api.example.test/profile   -H 'Origin: https://app.example.test'   -H 'Access-Control-Request-Method: PATCH'   -H 'Access-Control-Request-Headers: content-type, x-csrf-token'
\n

В ожидаемом ответе должны быть статус 2xx и совместимые значения: exact Access-Control-Allow-Origin, Access-Control-Allow-Methods: PATCH и Access-Control-Allow-Headers с нужными именами. Для credentialed flow нужен также Access-Control-Allow-Credentials: true. Конкретный набор дополнительных заголовков зависит от вашего API.

\n

Затем отправьте actual request в тестовой среде с тестовыми значениями. Не подставляйте production cookie или token в терминал, историю shell и статью.

\n
curl -i -X PATCH https://api.example.test/profile   -H 'Origin: https://app.example.test'   -H 'Content-Type: application/json'   -H 'X-CSRF-Token: TEST_TOKEN_FROM_THE_SAME_SESSION'   -H 'Cookie: session=TEST_SESSION'   --data '{\"displayName\":\"Ada\"}'
\n

curl не моделирует браузерную CORS-блокировку: он покажет ответ независимо от CORS headers. Это полезно для проверки API и side effect, но финальный вывод о поведении frontend делайте по browser test или HAR. Если curl с плохим origin всё равно изменяет запись, это не «ошибка CORS», а отсутствие серверной проверки, которую нельзя компенсировать заголовком ответа.

\n

CSRF: доказательство до побочного эффекта

\n

CSRF возникает, когда браузер пользователя автоматически прикладывает authentication cookie к запросу, инициированному чужим сайтом. Сервер видит действительную сессию, но не отличает намеренное действие от подделанного. Для state-changing endpoint обычно применяют synchronizer token, signed double-submit cookie, проверку Origin/Referer или комбинацию методов согласно threat model и возможностям фреймворка. GET и другие safe-methods не должны менять состояние.

\n

Для API с cookie-аутентификацией custom header удобен тем, что чужой origin не может прочитать token из приложения и выставить такой header обычной формой. Но этот подход остаётся безопасным только вместе с серверной проверкой token и узким CORS allow-list. Наличие заголовка в OPTIONS не доказывает валидность его значения в PATCH.

\n
const allowedOrigins = new Set([\n  'https://app.example.test',\n]);\n\nfunction handleProfilePatch(request, session) {\n  const origin = request.headers.get('Origin');\n  const token = request.headers.get('X-CSRF-Token');\n\n  if (!allowedOrigins.has(origin)) {\n    return deny(403, 'origin-not-allowed');\n  }\n  if (!token || !constantTimeEqual(token, session.csrfToken)) {\n    return deny(403, 'csrf-token-invalid');\n  }\n  if (!session.user.can('profile:update')) {\n    return deny(403, 'forbidden');\n  }\n\n  return updateProfile(request.body);\n}
\n

Это псевдокод границы, а не готовый middleware. Реализация должна определить генерацию и срок жизни token, привязку к сессии, rotation, logout, способ constant-time сравнения, лимиты и журналирование. Проверки origin и token выполняйте до updateProfile. Даже принятый token не даёт пользователю доступа к чужому профилю: это уже authorization.

\n

Матрица диагностики

\n
НаблюдениеЧто оно подтверждаетЧего оно не подтверждаетСледующая проверка
OPTIONS вернул 204Preflight-контракт принят браузером для этой формыCookie, token и право на объектОтправить actual request и проверить серверный отказ/успех
В консоли CORS errorJavaScript не получил доступ к responseЧто actual request не выполнил side effectСверить status и запись операции на API boundary
Access-Control-Allow-Origin: *Возможен публичный CORS для запроса без credentialsБезопасность cookie-сессии и CSRFПроверить credentials mode и заменить wildcard на exact allow-list, если нужны cookies
PATCH с token получил 403Одна из серверных проверок отклонила запросКакая именно: origin, token или authorizationРазвести коды/логи причин и исключить side effect
POST-форма не вызвала OPTIONSЗапрос прошёл без preflightЧто операция безопаснаПроверить CSRF-защиту actual request
curl получил 200API ответил этому HTTP-клиентуЧто браузер отдаст response JavaScriptПовторить в браузере с тем же origin и credentials
\n

Пошаговая проверка в тестовой среде

\n
  1. Выберите один state-changing endpoint и запишите ожидаемый side effect: например, изменение только поля профиля тестового пользователя.
  2. Снимите контракт запроса: source origin, target origin, method, content type, credentials mode и точные имена headers. Проверяйте origin как полную строку, а не как suffix host.
  3. Выполните preflight с разрешённым и запрещённым method/header. Убедитесь, что отказ не меняет состояние.
  4. Повторите actual request с корректным token, без token и с token от другой тестовой сессии. Во всех отрицательных случаях проверьте отсутствие side effect.
  5. Повторите запрос с другим scheme, port и неизвестным subdomain. Не делайте вывод только по CORS response: проверьте серверное решение.
  6. Сопоставьте DevTools или HAR, API-лог и запись в базе/аудите. Особенно проверьте случай «браузер показал CORS error, но сервер вернул 2xx».
  7. После изменения allow-list очистите или дождитесь истечения preflight cache и повторите сценарии. Для динамического origin проверьте Vary: Origin на ответах, проходящих через cache.
  8. Закрепите контракт интеграционным тестом и назначьте владельца списка origins. Новый frontend origin добавляйте после проверки архитектуры и границ данных endpoint.
\n

Ограничения применимости

\n

Cookie delivery зависит не только от CORS. На неё влияют credentials mode, SameSite, Secure, Domain, Path, third-party cookie restrictions, redirects и политика конкретного браузера. Поэтому один Access-Control-Allow-Credentials не доказывает, что cookie ушла. И наоборот, отправленная cookie не означает, что JavaScript прочитает response.

\n

SameSite — полезный слой снижения риска, но не универсальная замена CSRF token. OWASP допускает узкие случаи, где его достаточно, только при одновременном выполнении нескольких условий: приложение не делит registrable domain с недоверенными хостами, GET не меняет состояние, cookie настроена строго и есть дополнительная проверка origin/referrer. В остальных архитектурах относитесь к SameSite как к defense in depth.

\n

Origin/Referer могут отсутствовать или иметь значение null в отдельных privacy-контекстах, поэтому policy должна явно описывать такие запросы. За прокси target origin нужно брать из доверенной конфигурации или корректно обработанных forwarded headers, а не слепо сравнивать с внутренним host. XSS на доверенном origin, утёкшая сессия, вредоносный service-to-service клиент, authorization flaw, rate limit и CSP лежат за пределами CSRF/CORS и требуют собственных controls.

\n

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

\n

Разбор можно считать завершённым, когда для endpoint названы разрешённые origins и credentials mode, воспроизводится preflight или объяснено его отсутствие, actual request проверяет CSRF proof до side effect, а authorization отделена от этой проверки. Есть тесты для правильного и неправильного origin, отсутствующего и чужого token, запрещённых method/header и cache-варианта. Если команда видит только заголовок CORS или только зелёный unit test token, контракт ещё не доказан целиком.

\n

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

\n" } diff --git a/editorial/agent-rewrites/174.json b/editorial/agent-rewrites/174.json index ad60cd2..bccd66a 100644 --- a/editorial/agent-rewrites/174.json +++ b/editorial/agent-rewrites/174.json @@ -2,6 +2,6 @@ "index": 174, "slug": "editorial-2023-03-practice-csrf-cors", "title": "Cookie API и виджет: как не перепутать CORS с CSRF", - "excerpt": "Разберите CORS и CSRF по отдельности: один механизм управляет доступом JavaScript к ответу, другой решает на сервере, можно ли принять изменение данных.", - "contentHtml": "

Виджет на https://app.example.test вызывает API на https://api.example.test. После релиза в DevTools появляется CORS error. Пользователь не видит профиль, а команда предлагает поставить Access-Control-Allow-Origin: *. Если API использует cookie, это не исправление. Браузер всё равно скроет credentialed response, а попытка отключить CSRF-проверку может открыть изменение данных с чужой страницы.

\n

Цена ошибки двойная. Рабочий интерфейс перестаёт получать данные. Одновременно сервер может начать принимать state-changing запрос только по cookie. Тогда злоумышленник не обязан читать ответ: ему достаточно заставить браузер жертвы отправить перевод, сменить адрес или удалить запись.

\n

Тезис: CORS и CSRF отвечают на разные вопросы. CORS определяет, получит ли JavaScript cross-origin доступ к response. CSRF-защита проверяет на сервере, действительно ли запрос на изменение состояния пришёл из разрешённого сценария. Исправляйте эти контуры раздельно.

\n

Механизм по шагам

\n

Сначала браузер определяет origin. Это комбинация scheme, host и port. Для https://app.example.test и https://api.example.test host различается, поэтому запрос cross-origin. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin.

\n

Клиент может запросить credentials: например, передать cookie через fetch(url, { credentials: 'include' }). Это только намерение клиента. Сервер должен вернуть точный Access-Control-Allow-Origin для разрешённого origin и Access-Control-Allow-Credentials: true, если браузер должен открыть response JavaScript-коду. Wildcard * не совместим с credentialed CORS.

\n

CORS не является authorization. Успешная проверка CORS не означает, что пользователь вошёл, имеет право менять конкретный ресурс или передал CSRF-доказательство. Сервер должен выполнить аутентификацию и authorization независимо от CORS.

\n

CSRF появляется потому, что браузер может приложить cookie к cross-site запросу. Простая HTML-форма способна отправить POST с application/x-www-form-urlencoded без доступа к ответу и без preflight. Если endpoint меняет состояние только по cookie, такой запрос опасен.

\n

Для stateful API сервер обычно хранит CSRF-token в сессии и требует его в form field или custom header. Обработчик сравнивает token до mutation. Отсутствующий или неверный token даёт отказ. Origin или Referer check может добавить защиту, но не заменяет token, authorization и проверку бизнес-прав.

\n

Конкретный пример

\n

Ниже учебный пример политики. Он не открывает сеть, не создаёт cookie, не запускает браузер и не доказывает поведение конкретного production API. В нём показаны две независимые проверки: CORS для чтения ответа и CSRF для изменения состояния.

\n
const corsOrigins = new Set(['https://app.example.test']);\nconst csrfOrigins = new Set([\n  'https://app.example.test',\n  'https://api.example.test',\n]);\n\nfunction checkRequest({ origin, credentials, method, csrfToken }) {\n  const cors = corsOrigins.has(origin) &&\n    (!credentials || origin !== '*');\n\n  const safeMethod = new Set(['GET', 'HEAD', 'OPTIONS']).has(method);\n  const csrf = safeMethod ||\n    (csrfOrigins.has(origin) && csrfToken === 'token-from-session');\n\n  return { cors: cors ? 'allow' : 'deny', csrf: csrf ? 'allow' : 'deny' };\n}\n\n// Учебные проверки, не production-конфигурация:\ncheckRequest({\n  origin: 'https://app.example.test',\n  credentials: true,\n  method: 'POST',\n  csrfToken: 'token-from-session',\n}); // { cors: 'allow', csrf: 'allow' }\n\ncheckRequest({\n  origin: 'https://evil.example',\n  credentials: true,\n  method: 'POST',\n  csrfToken: undefined,\n}); // { cors: 'deny', csrf: 'deny' }
\n

Строка token-from-session здесь условна. В реальном приложении token должен быть непредсказуемым, связанным с сессией и сгенерированным безопасным источником случайности. Сравнение выполняет сервер до побочного эффекта. Не переносите этот код в middleware без проверки cookie policy, proxy и framework-контракта.

\n

Preflight полезен для диагностики формы запроса. Custom header вроде X-CSRF-Token или нестандартный content type часто вызывает OPTIONS-проверку. Но preflight не подтверждает token, сессию и право пользователя. Он проверяет, разрешает ли CORS-политика указанный origin, method и header. Поэтому нельзя считать preflight самостоятельной CSRF-защитой.

\n
\"Схема:
CORS открывает JavaScript доступ к ответу только для точного origin. CSRF отдельно требует доказательство до изменения данных. Иллюстрация учебная: она не показывает реальный браузерный запуск или лог API.
\n

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

\n
СимптомПричинаПроверкаДействие
В консоли CORS error при cookie-запросеWildcard или отсутствует точный originСверить Origin, Access-Control-Allow-Origin, credentials и Vary: OriginОставить allow-list точных origin; не подставлять любое входное значение
OPTIONS проходит, POST меняет данные без tokenPreflight ошибочно приняли за CSRF-защитуОтправить form-shaped POST без custom header и проверить серверный ответПроверять CSRF-token до mutation для всех state-changing методов
403 после добавления tokenToken не связан с текущей сессией или не доходит через proxyПроверить источник token, cookie, заголовок, нормализацию и точку отказаИсправить передачу и проверку; не отключать middleware целиком
Данные не читаются, но запись всё равно меняетсяCORS скрывает response, но сервер принимает запрос по cookieСмотреть server log и статус actual request отдельно от сообщения браузераДобавить серверный CSRF-контроль и authorization
\n

Порядок действий

\n
  1. Зафиксируйте страницу-источник, API URL, scheme, host и port. Не называйте два адреса одним «сайтом» без проверки origin.
  2. Повторите запрос без изменений конфигурации. Сохраните method, content type, request headers, response status и серверный log.
  3. Проверьте CORS отдельно. Для credentialed запроса нужен точный разрешённый origin и согласованный credentials response. Для публичного non-credentialed ресурса wildcard может быть уместен, но это другой контракт.
  4. Перечислите все методы и endpoints, которые меняют состояние. Для каждого укажите источник CSRF-token и место проверки до mutation.
  5. Проверьте отрицательный путь: POST из чужого origin, POST без token, неверный token и form-shaped POST без preflight должны получить отказ или не изменить состояние.
  6. Проверьте authorization после CSRF. Валидный token не даёт пользователю право менять чужой ресурс.
  7. Проверьте реальный browser flow с двумя контролируемыми origin. Учебная функция выше годится для проверки логики ветвлений, но не заменяет integration test.
  8. Добавьте регрессионные проверки и наблюдение за отказами. В журнале не записывайте полный token и cookie.
\n

Ограничения

\n

Эта схема предполагает cookie-based authentication и браузерный клиент. Она не описывает OAuth bearer token в заголовке, webhook, native app или server-to-server вызов. Для таких клиентов модель угроз и способ доказать полномочия будут другими.

\n

SameSite помогает ограничить отправку cookie, но не отменяет серверную проверку. Его результат зависит от атрибутов cookie, браузера, контекста навигации и окружения. Origin может отсутствовать или иметь значение null. Proxy может изменить набор видимых заголовков. Эти случаи нужно включить в отдельную политику, а не молча считать безопасными.

\n

XSS в доверенном origin может обойти многие CSRF-меры, потому что вредоносный код действует внутри разрешённого контекста. Поэтому исправление CSRF не заменяет защиту от XSS, управление cookie и контроль прав.

\n

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

\n

Сценарий готов, если команда может предъявить для одного state-changing endpoint четыре независимых доказательства: точный allow-list origin, ожидаемый CORS response, проверку CSRF-token до mutation и отказ при чужом origin или неверном token. В реальном browser test легитимный запрос читает response и меняет только разрешённый ресурс. Отрицательные запросы получают 403 или эквивалентный отказ, а состояние не меняется. Ни один из этих результатов нельзя заменять одним сообщением CORS в консоли.

\n

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

\n" + "excerpt": "Разделяем три проверки для cookie-аутентифицированного API: origin, доступ JavaScript к ответу и право принять изменение данных. На одном PATCH разбираем preflight, CSRF-токен и отрицательные тесты.", + "contentHtml": "

Симптом выглядит знакомо: виджет на https://app.example.test вызывает API на https://api.example.test, а браузер показывает CORS error. Профиль не загружается, и команда предлагает добавить Access-Control-Allow-Origin: * или отключить CSRF-проверку. Цена такой правки — не только сломанный интерфейс. Если API принимает cookie автоматически, злоумышленник может добиться изменения данных, даже не умея прочитать ответ.

\n

Разберём один cookie-аутентифицированный endpoint PATCH /profile. Для него нужно ответить на три разных вопроса: какой origin отправил запрос, может ли JavaScript этого origin прочитать response и доказал ли запрос право изменить состояние. CORS отвечает только на второй вопрос. CSRF-защита отвечает на третий. Аутентификация и authorization остаются отдельными серверными проверками.

\n

Сначала зафиксируйте границу

\n

Origin — это комбинация scheme, host и port. Поэтому https://app.example.test и http://app.example.test различаются по scheme, а https://app.example.test:8443 — по port. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin. Проверка host.endsWith('example.test') опасна: строка может пропустить неподконтрольный поддомен или вовсе другой домен.

\n

Запишите для одной операции полный source origin, URL API, method, content type, режим credentials и имена заголовков. Сообщение в консоли не показывает всего пути. Нужно отличить отказ preflight, отправленный actual request с недоступным для JavaScript ответом и серверный отказ до mutation.

\n
\"Контракт
Один запрос проходит через независимые границы: browser origin, CORS-доступ к response, CSRF-доказательство и authorization. Схема учебная и не заменяет трассировку конкретного браузера или API.
\n

Что именно делает CORS

\n

CORS, Cross-Origin Resource Sharing, — протокол поверх HTTP, которым сервер сообщает браузеру, можно ли передать cross-origin response коду страницы. Если клиент вызывает fetch(url, { credentials: 'include' }), credentials mode влияет на CORS-контракт: сервер должен вернуть точный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * нельзя использовать как разрешённый origin для credentialed response.

\n

Когда разрешённый список вычисляется динамически, response должен различаться по заголовку Origin; для промежуточного кеша это означает Vary: Origin. Нельзя без проверки копировать входной Origin в Access-Control-Allow-Origin. Сначала сравните его с фиксированным allow-list, затем сформируйте заголовок. Allow-list должен описывать конкретную причину доступа, а не все поддомены компании.

\n

CORS не делает пользователя авторизованным и не защищает данные от запроса, который сервер всё равно выполнит. Браузер может скрыть response от JavaScript, но запрос уже мог дойти до API. Поэтому проверку CORS-ответа нельзя ставить вместо authentication, authorization или CSRF-контроля.

\n

Почему preflight не является CSRF-защитой

\n

Для запроса с нестандартной формой браузер часто сначала отправляет OPTIONS. Например, PATCH, JSON-тело и заголовок X-CSRF-Token приводят к preflight. В нём браузер сообщает origin, будущий method и имена заголовков:

\n
OPTIONS /profile HTTP/1.1\nHost: api.example.test\nOrigin: https://app.example.test\nAccess-Control-Request-Method: PATCH\nAccess-Control-Request-Headers: content-type,x-csrf-token\n\nHTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://app.example.test\nAccess-Control-Allow-Credentials: true\nAccess-Control-Allow-Methods: PATCH\nAccess-Control-Allow-Headers: Content-Type, X-CSRF-Token\nVary: Origin
\n

Успешный OPTIONS означает только, что политика CORS разрешила форму следующего запроса. Он не сравнивает token с сессией и не знает, имеет ли пользователь право редактировать профиль. Более того, обычная HTML-форма может отправить state-changing POST без custom header и без preflight. Серверу нельзя делать вывод «раз preflight не было, значит запрос безопасен».

\n

Важна и обратная сторона: custom header создаёт удобную границу для API-клиента, потому что чужая страница не может произвольно добавить его к cross-origin запросу без прохождения CORS. Но это часть общей схемы, а не единственная причина доверять запросу. Заголовок нужно проверить на сервере, связать с сессией и выполнить проверку до изменения данных.

\n

Учебный endpoint: решение до mutation

\n

Ниже — псевдо-JavaScript для сервера. trustedOrigins и имя заголовка — проектные значения. constantTimeEqual должна быть безопасной реализацией сравнения из выбранного фреймворка или криптографической библиотеки; функция в примере не реализована намеренно. Порядок важнее названий: сначала границы запроса, затем CSRF-доказательство, затем право пользователя, и только после этого побочный эффект.

\n
const trustedOrigins = new Set([\n  'https://app.example.test',\n]);\n\nfunction corsHeaders(origin) {\n  if (!trustedOrigins.has(origin)) return null;\n  return {\n    'Access-Control-Allow-Origin': origin,\n    'Access-Control-Allow-Credentials': 'true',\n    'Vary': 'Origin',\n  };\n}\n\nfunction patchProfile(request, session) {\n  const origin = request.headers.get('Origin');\n  const token = request.headers.get('X-CSRF-Token');\n\n  if (!trustedOrigins.has(origin)) {\n    return deny(403, 'origin-not-allowed');\n  }\n  if (!token || !constantTimeEqual(token, session.csrfToken)) {\n    return deny(403, 'csrf-check-failed');\n  }\n  if (!session.user.can('profile:update')) {\n    return deny(403, 'not-authorized');\n  }\n\n  return applyProfileChange(request.body);\n}
\n

Код не является готовым middleware. Реальный компонент должен сам получить session после проверки cookie, ограничить размер и схему тела, корректно обработать повтор и записать безопасную причину отказа. Полный CSRF-токен и cookie нельзя класть в логи. Если используется stateful-сессия, OWASP рекомендует synchronizer token: сервер создаёт непредсказуемый токен, хранит его в сессии и сравнивает со значением из заголовка или формы. Для stateless-схемы нужен подходящий double-submit вариант, причём наивное сравнение двух cookie без привязки к сессии имеет отдельные риски.

\n

Воспроизводимая проверка через curl

\n

curl не применяет browser policy и потому не может сам показать, что JavaScript увидит response. Он полезен для проверки фактических HTTP-заголовков и серверного статуса. Запускайте команды против тестового API, подставляя только тестовые значения SESSION_COOKIE и CSRF_TOKEN. В production-сессию их не копируйте.

\n
export API='https://api.example.test/profile'\nexport SESSION_COOKIE='test-session-cookie'\nexport CSRF_TOKEN='test-csrf-token'\n\n# 1. Разрешённый preflight: ожидаем 204 и узкий набор разрешений.\ncurl -i -X OPTIONS \"$API\" \\\n  -H 'Origin: https://app.example.test' \\\n  -H 'Access-Control-Request-Method: PATCH' \\\n  -H 'Access-Control-Request-Headers: content-type,x-csrf-token'\n\n# 2. Разрешённый actual request: ожидаем успешный ответ и одно изменение.\ncurl -i -X PATCH \"$API\" \\\n  -H 'Origin: https://app.example.test' \\\n  -H 'Content-Type: application/json' \\\n  -H \"X-CSRF-Token: $CSRF_TOKEN\" \\\n  --cookie \"session=$SESSION_COOKIE\" \\\n  --data '{\"displayName\":\"Test User\"}'\n\n# 3. Чужой origin и отсутствие token: ожидаем отказ и отсутствие mutation.\ncurl -i -X PATCH \"$API\" \\\n  -H 'Origin: https://evil.example' \\\n  -H 'Content-Type: application/json' \\\n  --cookie \"session=$SESSION_COOKIE\" \\\n  --data '{\"displayName\":\"Unexpected Change\"}'
\n

В третьей проверке недостаточно увидеть 403. Сверьте состояние профиля и серверный log: обработчик не должен вызвать applyProfileChange. Добавьте ещё два отрицательных запуска — с разрешённым origin, но без token, и с token от другой тестовой сессии. Для каждого заранее запишите ожидаемый статус и признак отсутствия изменения.

\n

Матрица симптомов

\n
НаблюдениеВероятная границаЧто собратьСледующее действие
OPTIONS отклонёнCORS не разрешил method или headerOrigin, Access-Control-Request-Method, Access-Control-Request-Headers, статусСузить и согласовать фактический allow-list; CSRF middleware не менять
OPTIONS успешен, PATCH получает 403Серверный CSRF или authorizationПричина отказа до mutation, наличие token, сессия и permissionРазделить token check и право на объект; не добавлять новый CORS origin
PATCH изменяет данные, но JavaScript видит CORS errorCORS response оформлен неправильно после actual requestServer status, response headers и факт измененияИсправить response contract, сохранив CSRF и authorization
Всё работает без custom headerВозможно, endpoint принимает простой form-shaped запрос по cookiePOST/PUT-путь без preflight и без CSRF proof в тестовой сессииЗакрыть каждый state-changing путь серверной проверкой
Разрешён чужой поддоменСлабое сравнение host или динамическое эхо originПолные origin со scheme и port, конфигурацию allow-listСравнивать нормализованный origin с фиксированными значениями
\n

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

\n
  1. Выберите одну state-changing операцию и зафиксируйте цену ошибки: потеря видимости ответа, отклонённая запись или реально выполненное изменение.
  2. Снимите точный request contract: source origin, URL, method, content type, credentials и заголовки. Слово «фронтенд» не заменяет эти значения.
  3. Проверьте origin как scheme/host/port tuple. Отдельно протестируйте другой scheme, port и похожее имя домена.
  4. Проверьте CORS response и preflight. Для динамического origin сверяйте также Vary: Origin и поведение кеша.
  5. Проверьте CSRF proof до mutation: отсутствующий, неверный и принадлежащий другой сессии token должны завершаться отказом.
  6. Проверьте authorization после CSRF: валидный token не даёт пользователю права менять чужой профиль.
  7. Повторите happy path в реальном тестовом браузере, а не только через curl. Сопоставьте DevTools Network с серверным log.
  8. Закрепите положительный и отрицательные сценарии интеграционным тестом. Не записывайте в fixture реальные cookie, token или персональные данные.
\n

Ограничения применимости

\n

Схема относится к браузерному клиенту, который использует cookie или другую автоматически прикладываемую credential. Для bearer token, который JavaScript явно кладёт в заголовок и не получает из cookie, классическая CSRF-модель обычно отличается; это не отменяет проверки authentication, authorization и защиты от XSS. Webhook и server-to-server вызовам нужен свой контракт подписи, replay-защиты и прав.

\n

SameSite ограничивает отправку cookie, но его итог зависит от атрибутов cookie, контекста навигации, браузера и политики third-party cookies. Его разумно рассматривать как слой защиты, а не как повод удалить серверную проверку. Origin иногда отсутствует или имеет значение null; proxy может изменить наблюдаемую картину; redirect может привести к другому origin. Для этих случаев нужна явная политика отказа или отдельная проверка, а не молчаливое разрешение.

\n

GET не должен менять состояние. Если legacy endpoint нарушает это правило, его нельзя считать безопасным только из-за метода: OWASP рекомендует защитить такой ресурс от CSRF и планировать миграцию. XSS на доверенном origin также выходит за рамки CORS и может действовать изнутри приложения. Поэтому нужны отдельные меры для XSS, cookie policy, CSP, аудита и ограничения прав.

\n

Учебный код не моделирует конкретный framework, браузерный кеш, все правила cookies или сетевые proxy. Его результат — проверяемый порядок условий, а не доказательство безопасности production API. Доказательством служит повторяемый browser/integration тест вместе с server-side evidence на контролируемой тестовой сессии.

\n

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

\n

Один endpoint можно считать проверенным, если для него названы разрешённые origin и credentials-контракт, воспроизведён preflight либо объяснено его отсутствие, а server log показывает проверку CSRF до побочного эффекта и authorization после неё. Тестовая сессия должна успешно прочитать разрешённый response и изменить только свой профиль. Другой origin, пустой token, token другой сессии и попытка изменить чужой объект должны завершаться отказом без изменения состояния. Один заголовок CORS или один успешный ручной запрос этих доказательств не заменяет.

\n

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

\n" } diff --git a/editorial/agent-rewrites/175.json b/editorial/agent-rewrites/175.json index cfa2f05..d31101d 100644 --- a/editorial/agent-rewrites/175.json +++ b/editorial/agent-rewrites/175.json @@ -1 +1,7 @@ -{"index":175,"slug":"editorial-2023-02-field-sessions-auth","title":"Старый session ID после rotation: как проверить logout и не закрыть новую сессию","excerpt":"После renewal старая вкладка может отправить прежний ID, а logout — изменить только интерфейс. Разбираем границу между cookie и серверной записью, stale-события и проверяемый маршрут без выдуманного browser trace.","contentHtml":"

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

\n

Цена ошибки — не только плохой UX. Если сервер принимает старый ID после rotation, украденный или повторно отправленный идентификатор остаётся рабочим. Если logout по старому ID отзывает successor, пользователь теряет новую сессию после обычной сетевой задержки. Если система очищает только cookie, интерфейс говорит «вы вышли», но следующий запрос может пройти.

\n

Тезис статьи простой: cookie переносит идентификатор, но не принимает решение о доступе. Серверная запись хранит статус ID, его поколение и связь с lineage. Перед защищённым действием сервер принимает только current active ID. Rotation заменяет current ID. Logout отзывает запись в согласованном scope и отдельно просит браузер очистить cookie. Эти события нельзя свести к одной строке или одному флагу.

\n

Сначала разделите четыре объекта

\n

Cookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.

\n

Session record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.

\n

Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.

\n

Authorization остаётся отдельным слоем. Active session отвечает на вопрос «какая сессия предъявлена?». Проверка прав отвечает на вопрос «может ли она выполнить это действие?». Не выдавайте active ID больше полномочий, чем описывает политика handler.

\n
Контракт одной сессии
ОбъектЧто он решаетМинимальная проверкаЧего он не доказывает
CookieКакой ID браузер отправитname, host, Path и атрибуты scopeЧто сервер считает ID active
Session recordПринимать ли предъявленный ID сейчасstatus и совпадение с currentЧто у сессии есть нужное право
Lineage pointerКакой ID является successorОдин current после rotationЧто logout означает для всех устройств
AuthorizationМожно ли выполнить конкретное действиеПроверка права после session checkЧто cookie доставлена безопасно
\n

Почему logout не заканчивается очисткой cookie

\n

У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.

\n

Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.

\n

Scope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.

\n
Cookie: __Host-session=fixture-s-2\n\n// server-side decision, учебная модель\nrecord.status === 'active'\n  && currentByLineage[record.lineage] === record.id\n  && can(record, action)\n\n// logout-current\nrecord.status = 'revoked'\ndelete currentByLineage[record.lineage]\nSet-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT
\n

Это учебный фрагмент. Он не задаёт формат production cookie, не генерирует секрет и не доказывает, что конкретный framework или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.

\n

Rotation меняет право предъявления

\n

Rotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.

\n

Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.

\n

Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.

\n
\"Жизненный
Иллюстрация показывает правило для одной lineage и синхронных учебных вызовов. Это не browser trace, не журнал инцидента и не доказательство порядка реальных HTTP-ответов.
\n

Отрицательный путь важнее зелёного login

\n

Положительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.

\n

Отдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый ID проходит после renewalHandler не проверяет current или record не стал rotatedСопоставить безопасный ID, status и current pointer на момент обработкиОтклонять rotated ID до эффекта и атомарно менять pointer
Logout меняет только экранОчищена cookie, но сервер не revoke-нул recordПроверить audit logout и status той же записиRevoke на сервере, затем вернуть clear cookie
Поздний logout закрывает новую сессиюStale ID трактуется как currentСравнить предъявленный ID с currentByLineageВыбрать logout-current или явный более широкий scope
После clear видна другая cookieIssue и clear расходятся по Path или DomainСравнить атрибуты Set-Cookie без значенияПовторить name, Path и Domain при наличии
Сбой только в одном браузереОтличается cookie policy или порядок ответовПовторить разрешённый сценарий на конкретной версии и собрать метаданныеНе менять server contract до подтверждения различия
\n

Учебный fixture: что он доказывает

\n

Минимальный fixture создаёт fixture-s-1, выполняет rotation и получает fixture-s-2. Повторная rotation со старым ID возвращает reject. Stale logout со старым ID также возвращает reject и оставляет successor active. Logout с текущим ID переводит successor в revoked и удаляет current pointer.

\n
const first = createTeachingSessionState();\nconst replacement = rotateTeachingSession(first, 'fixture-s-1');\nconst staleRotation = rotateTeachingSession(replacement.state, 'fixture-s-1');\nconst staleLogout = logoutTeachingSession(replacement.state, 'fixture-s-1');\n\nif (staleRotation.accepted || staleLogout.accepted) {\n  throw new Error('old ID changed current session');\n}\nif (replacement.state.records['fixture-s-2'].status !== 'active') {\n  throw new Error('successor is not active');\n}
\n

Пример ограничен памятью процесса. Он не проверяет HTTP endpoint, реальный Set-Cookie, браузерное хранилище, TLS, random ID, серверные часы, CSRF, reauthentication, несколько устройств, распределённую блокировку или конкурентные запросы. Команда node web/scripts/upgrade-2023-02.mjs --verify-fixture подтверждает assertions учебной модели, а не состояние неизвестной production-системы.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите внешний status, защищённый эффект, безопасную корреляцию операции и момент обработки. Не называйте историю browser trace, если его не собирали.
  2. Спрячьте секрет. Используйте surrogate ID и не помещайте bearer value в лог, таблицу или снимок.
  3. Назовите состояние. Разделите cookie delivery, server record, lineage pointer и authorization. Для каждого объекта назначьте владельца.
  4. Проверьте fixture. Выполните положительные и отрицательные переходы: rotation, stale rotation, stale logout и logout current.
  5. Проверьте реализацию. В разрешённой среде сравните status и current pointer до protected effect. Отдельно сверяйте атрибуты issue и clear cookie.
  6. Проверьте гонку. Для реального хранилища зафиксируйте гарантию, которая не допускает двух successor и не позволяет stale событию изменить новую запись.
  7. Выберите scope logout. Зафиксируйте logout-current, logout-lineage или logout-all как разные операции с разными проверками.
  8. Сделайте малый diff. Исправляйте подтверждённый слой: серверный reject, атомарность, cookie scope, audit или UX после reject.
  9. Опишите откат. Не возвращайте retired ID в active ради быстрого rollback. Подготовьте совместимую миграцию или откат экрана при сохранённом reject старого ID.
\n

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

\n

Cookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.

\n

Эта модель не выбирает SQL, cache, signed token или конкретный framework. Она требует только одного current ID и явного решения для stale состояния. Если система не хранит lineage, можно выбрать другую реализацию, но тогда нужно доказать, как она отличает повторный старый ID от текущего и как предотвращает позднее изменение successor.

\n

Материал готов к применению как проверяемая схема, если команда может показать: predecessor и successor без секретов; status каждого в момент запроса; current pointer; scope logout; результат stale rotation и stale logout; совпадение name, Path и Domain у issue/clear cookie; и отдельную гарантию для конкурентной rotation. Один зелёный fixture или исчезнувшая cookie этот критерий не закрывают.

\n

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

\n"} +{ + "index": 175, + "slug": "editorial-2023-02-field-sessions-auth", + "title": "Старый session ID после rotation: как проверить logout и не закрыть новую сессию", + "excerpt": "После renewal старая вкладка не всегда означает старую cookie: запрос мог уйти раньше, прийти позже или иметь другой scope. Разбираем server-side проверку, stale-события, logout и воспроизводимый fixture.", + "contentHtml": "

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

\n

Сначала уточним важную деталь. Вкладки одного origin обычно используют общий cookie jar. Поэтому сама по себе «старая вкладка» не доказывает, что у неё остался старый cookie. Старый ID может быть внутри уже отправленного запроса, в явно заданном заголовке другого клиента, в cookie с другим Path или Domain либо в ответе, который пришёл позже. Это разные причины, но для сервера требование одно: predecessor после rotation не должен менять защищённое состояние.

\n

Цена ошибки двойная. Если старый ID всё ещё принимается, rotation почти не уменьшает окно повтора украденного или задержанного идентификатора. Если stale logout отзывает successor, пользователь теряет новую сессию из-за сетевой задержки. Правильная диагностика разделяет доставку cookie, состояние записи на сервере, указатель current и scope операции logout.

\n

Сначала разделите четыре объекта

\n

Cookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.

\n

Session record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.

\n

Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.

\n

Authorization остаётся отдельным слоем. Active session отвечает на вопрос «какая сессия предъявлена?». Проверка прав отвечает на вопрос «может ли она выполнить это действие?». Не выдавайте active ID больше полномочий, чем описывает политика handler.

\n
Контракт одной сессии
ОбъектЧто он решаетМинимальная проверкаЧего он не доказывает
CookieКакой ID браузер отправитname, host, Path и атрибуты scopeЧто сервер считает ID active
Session recordПринимать ли предъявленный ID сейчасstatus и совпадение с currentЧто у сессии есть нужное право
Lineage pointerКакой ID является successorОдин current после rotationЧто logout означает для всех устройств
AuthorizationМожно ли выполнить конкретное действиеПроверка права после session checkЧто cookie доставлена безопасно
\n

Почему logout не заканчивается очисткой cookie

\n

У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.

\n

Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.

\n

Scope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.

\n
Cookie: __Host-session=fixture-s-2\n\n// server-side decision, учебная модель\nrecord.status === 'active'\n  && currentByLineage[record.lineage] === record.id\n  && can(record, action)\n\n// logout-current\nrecord.status = 'revoked'\ndelete currentByLineage[record.lineage]\nSet-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT
\n

Это учебный фрагмент. Он не задаёт формат production cookie, не генерирует секрет и не доказывает, что конкретный framework или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.

\n

Rotation меняет право предъявления

\n

Rotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.

\n

Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.

\n

Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.

\n
\"Жизненный
Иллюстрация показывает правило для одной lineage и синхронных учебных вызовов. Это не browser trace, не журнал инцидента и не доказательство порядка реальных HTTP-ответов.
\n

Отрицательный путь важнее зелёного login

\n

Положительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.

\n

Отдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый ID проходит после renewalHandler не проверяет current или record не стал rotatedСопоставить безопасный ID, status и current pointer на момент обработкиОтклонять rotated ID до эффекта и атомарно менять pointer
Logout меняет только экранОчищена cookie, но сервер не revoke-нул recordПроверить audit logout и status той же записиRevoke на сервере, затем вернуть clear cookie
Поздний logout закрывает новую сессиюStale ID трактуется как currentСравнить предъявленный ID с currentByLineageВыбрать logout-current или явный более широкий scope
После clear видна другая cookieIssue и clear расходятся по Path или DomainСравнить атрибуты Set-Cookie без значенияПовторить name, Path и Domain при наличии
Сбой только в одном браузереОтличается cookie policy или порядок ответовПовторить разрешённый сценарий на конкретной версии и собрать метаданныеНе менять server contract до подтверждения различия
\n

Воспроизводимый fixture на Node.js

\n

Ниже — маленькая модель без зависимостей. Она проверяет инвариант: после rotation stale logout не отзывает successor, а logout current отзывает его. Команду можно запустить в Bash или Zsh на Node.js 18 и новее.

\n
node --input-type=module <<'NODE'\nconst state = {\n  current: 's-1',\n  records: new Map([['s-1', {status: 'active'}]]),\n};\n\nfunction rotate(presentedId) {\n  const record = state.records.get(presentedId);\n  if (!record || record.status !== 'active' || state.current !== presentedId) {\n    return {accepted: false, reason: 'stale-or-invalid'};\n  }\n\n  const successor = 's-2';\n  record.status = 'rotated';\n  state.records.set(successor, {status: 'active'});\n  state.current = successor;\n  return {accepted: true, successor};\n}\n\nfunction logoutCurrent(presentedId) {\n  const record = state.records.get(presentedId);\n  if (!record || record.status !== 'active' || state.current !== presentedId) {\n    return {accepted: false, reason: 'stale-or-invalid'};\n  }\n\n  record.status = 'revoked';\n  state.current = null;\n  return {accepted: true};\n}\n\nconst rotation = rotate('s-1');\nconst staleLogout = logoutCurrent('s-1');\nconst currentLogout = logoutCurrent(rotation.successor);\nconst result = {\n  rotation,\n  staleLogout,\n  currentLogout,\n  current: state.current,\n  successorStatus: state.records.get('s-2').status,\n};\n\nconsole.log(JSON.stringify(result, null, 2));\nif (staleLogout.accepted\n    || !currentLogout.accepted\n    || result.successorStatus !== 'revoked') {\n  process.exitCode = 1;\n}\nNODE
\n

Ожидаемый результат содержит \"staleLogout\": {\"accepted\": false, ...}, затем успешный currentLogout и статус successor revoked. Если убрать сравнение state.current !== presentedId, fixture покажет опасное поведение: stale logout сможет изменить новую сессию.

\n

Fixture намеренно не является HTTP-тестом. Он не генерирует криптографически случайный ID, не проверяет TLS, реальный Set-Cookie, браузерный cookie jar, CSRF, reauthentication, несколько устройств, задержку базы, распределённую блокировку и порядок сетевых ответов. Для настоящего сервиса этот тест нужно дополнить интеграционными запросами и конкурентным тестом хранилища.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите внешний status, защищённый эффект, безопасную корреляцию операции и момент обработки. Не называйте историю browser trace, если его не собирали.
  2. Спрячьте секрет. Используйте surrogate ID и не помещайте bearer value в лог, таблицу или снимок.
  3. Назовите состояние. Разделите cookie delivery, server record, lineage pointer и authorization. Для каждого объекта назначьте владельца.
  4. Проверьте fixture. Выполните положительные и отрицательные переходы: rotation, stale rotation, stale logout и logout current.
  5. Проверьте реализацию. В разрешённой среде сравните status и current pointer до protected effect. Отдельно сверяйте атрибуты issue и clear cookie.
  6. Проверьте гонку. Для реального хранилища зафиксируйте гарантию, которая не допускает двух successor и не позволяет stale событию изменить новую запись.
  7. Выберите scope logout. Зафиксируйте logout-current, logout-lineage или logout-all как разные операции с разными проверками.
  8. Сделайте малый diff. Исправляйте подтверждённый слой: серверный reject, атомарность, cookie scope, audit или UX после reject.
  9. Опишите откат. Не возвращайте retired ID в active ради быстрого rollback. Подготовьте совместимую миграцию или откат экрана при сохранённом reject старого ID.
\n

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

\n

Cookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.

\n

Эта модель не выбирает SQL, cache, signed token или конкретный framework. Она требует только одного current ID и явного решения для stale состояния. Если система не хранит lineage, можно выбрать другую реализацию, но тогда нужно доказать, как она отличает повторный старый ID от текущего и как предотвращает позднее изменение successor.

\n

Материал готов к применению как проверяемая схема, если команда может показать: predecessor и successor без секретов; status каждого в момент запроса; current pointer; scope logout; результат stale rotation и stale logout; совпадение name, Path и Domain у issue/clear cookie; и отдельную гарантию для конкурентной rotation. Один зелёный fixture или исчезнувшая cookie этот критерий не закрывают.

\n

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

\n" +} diff --git a/editorial/agent-rewrites/176.json b/editorial/agent-rewrites/176.json index 2d8edff..fa3da94 100644 --- a/editorial/agent-rewrites/176.json +++ b/editorial/agent-rewrites/176.json @@ -2,6 +2,6 @@ "index": 176, "slug": "editorial-2023-02-mechanism-sessions-auth", "title": "Сессия после rotation и logout: кто решает, действителен ли запрос", - "excerpt": "Cookie переносит идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и проверяемый отрицательный путь со старым идентификатором.", - "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает новый доступ, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии поздней операцией со старым ID и расследование, в котором нельзя назвать источник истины.

\n

Тезис статьи прост: cookie доставляет непрозрачный ID, а серверная запись принимает решение. Сервер хранит статус записи, связь между версиями сессии и current ID. Rotation заменяет current ID и делает старый ID недействительным. Logout отзывает запись и очищает cookie с тем же scope. Ни один флаг cookie не заменяет эти проверки.

\n
\"Контракт
Учебная схема разделяет доставку cookie и решение сервера. Она не показывает настоящий браузерный trace, reverse proxy или достаточность защиты от CSRF.
\n

Четыре факта, которые нельзя склеивать

\n

Аутентификация отвечает на вопрос «кто прошёл вход?». В этой модели она не представлена. Сервис может получать identity из другого механизма, но затем всё равно проверяет сессию.

\n

Cookie delivery отвечает на другой вопрос: какой ID браузер приложил к запросу. Имя, домен, путь, Secure и SameSite влияют на доставку. Наличие cookie не доказывает, что запись существует, не отозвана и относится к текущей версии.

\n

Server record хранит status, срок и поколение. Обработчик находит запись, проверяет active status, expiry и current ID, а потом передаёт контекст в authorization. Authorization отдельно решает, может ли identity выполнить конкретное действие.

\n
Слои сессии и границы решения
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок и scopeЧто ID действителен
Server recordПринимать ли ID?status, expiry, currentПраво на действие
LineageКакой ID заменил старый?generation или successorАтомарность двух запросов
AuthorizationМожно ли выполнить операцию?роль, ресурс, действиеБезопасность cookie
\n

Границы флагов cookie

\n

Secure ограничивает отправку по защищённому каналу. Он не шифрует запись сессии и не отзывает её. HttpOnly скрывает значение от обычного JavaScript API. Он уменьшает риск кражи через клиентский код, но не устраняет XSS и не запрещает браузеру приложить cookie.

\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты, а не замена CSRF-проверке mutation endpoint. Сценарии с embed, federation и несколькими доменами требуют отдельного решения.

\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Сервер не должен принимать ID только потому, что cookie пришла, и не должен считать удаление cookie доказательством logout. Browser lifetime и server expiry — разные часы.

\n

Префикс __Host- подходит для cookie одного host: нужны Secure, Path=/ и отсутствие Domain. Это не граница авторизации. Административный маршрут всё равно проверяет права на сервере. Для нескольких поддоменов нужен другой scope и явное описание его владельца.

\n

Rotation заменяет current ID

\n

Rotation меняет идентификатор, который сервер считает текущим. Операция переводит старую запись в rotated, создаёт successor и передвигает указатель current. Старый ID не получает новый TTL. Он возвращает отказ вроде stale-session.

\n
const current = sessions.findById(request.cookies[SESSION_NAME]);\n\nif (!current || current.status !== 'active' || current.id !== lineage.currentId) {\n  return response.status(401).json({ error: 'stale-session' });\n}\n\nconst next = sessions.rotateAtomically({ oldId: current.id, expectedGeneration: current.generation });\nresponse.setHeader('Set-Cookie', serializeSessionCookie(next.id));
\n

Это учебный фрагмент. Он показывает порядок проверки и условие compare-and-swap, но не готовый adapter для конкретной базы. В реальном хранилище нужно определить транзакцию, уникальность successor и поведение повторной доставки.

\n

Без линейзации два запроса могут прочитать один active ID и выпустить двух successor. Нельзя лечить эту гонку увеличением TTL. Нужен атомарный переход от ожидаемого поколения.

\n

Logout отзывает серверную запись

\n

Logout-current отзывает одну текущую запись. Logout-lineage отзывает цепочку одного входа. Logout-all отзывает все записи identity. Это разные операции. Endpoint должен назвать scope. Иначе поздний logout старой вкладки выключит новую сессию или оставит действующий successor.

\n

После отзыва сервер очищает cookie с тем же именем, доменом и путём. Это улучшает UX и уменьшает повторные запросы. Источником истины остаётся status серверной записи.

\n
const result = logoutCurrent({ presentedId: request.cookies[SESSION_NAME], lineageId: request.sessionLineage });\nif (result.kind === 'stale-session') return clearCookie(response).status(401).end();\nsessions.revoke(result.currentId);\nreturn clearCookie(response).status(204).end();
\n

Старый ID после rotation не должен отзывать successor, если выбран logout-current. Это отрицательный путь. Happy path с текущим ID его не проверяет.

\n

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

\n
Диагностика рассинхронизации cookie и серверной записи
СимптомПричинаПроверкаДействие
Cookie есть, но ответ 401Запись revoked, rotated или expiredСопоставить ID, status и currentВернуть единый reject и очистить cookie
Старый запрос изменил данныеПроверили наличие ID, но не status/currentПовторить запрос после rotationПроверять запись до эффекта
Старая вкладка выключила новуюLogout отзывает всю lineageСопоставить ID и scope в логеЯвно выбрать logout-current или logout-all
Стали действующими два IDRotation не линеаризованаОтправить два запроса одного generationДобавить транзакцию или conditional update
Прошла cross-site mutationSameSite принят за полную CSRF-защитуПроверить Origin и CSRF-контрактДобавить отдельную серверную проверку
\n

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

\n
  1. Назовите защищённый handler и его действие.
  2. Опишите владельца browser delivery, server record, lineage и authorization.
  3. Зафиксируйте active, rotated, revoked и expired и ответ для каждого состояния.
  4. Проверьте вход, запрос с current ID и успешную rotation.
  5. Проверьте старый ID после rotation: он получает reject и не меняет successor.
  6. Проверьте выбранный logout scope. Старый logout не меняет новую сессию при logout-current.
  7. Проверьте Set-Cookie и очистку в разрешённом интеграционном окружении. Unit fixture не доказывает поведение браузера.
  8. Сопоставьте active session с отдельной authorization-проверкой.
\n

Ограничения модели

\n

Пример не генерирует секреты, не читает cookie jar и не отправляет HTTP. Имена fixture-s-1, generation и фиксированный TTL учебные. Их нельзя копировать как production ID. Модель не покрывает распределённые блокировки, clock skew, несколько устройств, CORS, CSRF policy, reauthentication и reverse proxy.

\n

Документы IETF и NIST описывают протокол и термины, но не доказывают корректность конкретной платформы. Browser test не доказывает атомарность базы. Storage test не доказывает scope Set-Cookie. Эти границы проверяют отдельно.

\n

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

\n

Механизм готов к интеграционной проверке, если для одного handler видны четыре результата: current ID проходит до authorization; rotated ID получает reject без изменения successor; выбранный logout отзывает ровно ожидаемые записи; сервер принимает решение независимо от наличия cookie. В отчёте есть correlation ID, lineage, generation и причина отказа, но нет самого секрета.

\n

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

\n

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

\n" + "excerpt": "Cookie доставляет идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и отрицательные проверки со старым идентификатором.", + "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает доступ: обработчик проверяет только наличие cookie и сразу вызывает изменение данных. Цена ошибки измеряется не внешним видом страницы, а записью, которая прошла после отзыва права, или потерей новой сессии из-за позднего ответа старой вкладки.

\n

Разберём один вопрос: какие проверки должны пройти до защищённого действия. Cookie — это транспорт непрозрачного идентификатора. Серверная запись — источник состояния. Она хранит статус, срок действия и принадлежность к текущей цепочке. В этой статье выбрана строгая политика: после rotation старый ID сразу отвергается, а logout-current отзывает только предъявленную текущую запись. Другие политики допустимы, но их границы надо назвать в контракте.

\n
\"Схема
Cookie сообщает серверу, какой идентификатор пришёл. Решение зависит от status, текущей lineage, срока и отдельной проверки authorization. Схема учебная: она не заменяет тест браузера, CSRF-контракт или проверку атомарности хранилища.
\n

Cookie доставляет ID, а не доверие

\n

HTTP-сервер отправляет cookie через Set-Cookie, а браузер возвращает подходящие значения в заголовке Cookie. Так описывает обмен RFC 6265. Сервер может использовать значение как ключ к состоянию, но сам факт доставки не подтверждает, что ключ существует, не отозван и относится к последнему входу.

\n
Слои, которые часто ошибочно объединяют
СлойВопросМинимальная проверкаЧего она не доказывает
Cookie deliveryКакой ID прислал user agent?Имя, scope и значение заголовкаЧто запись активна
Server recordМожно ли принять этот ID сейчас?status, expiry и связь с currentПраво на конкретный ресурс
LineageКакой ID заменил предыдущий?generation или successorЧто переход выполнен атомарно
AuthorizationМожно ли выполнить действие?identity, ресурс и операцияЧто cookie безопасно доставлена
\n

Если cookie не пришла, это проблема браузерного scope или транспорта. Если ID пришёл, но запись revoked, это ожидаемый отказ сессии. Если запись active, но роль не разрешает удаление, отказ должен прийти из authorization. Повторная отправка запроса и увеличение TTL не исправляют ни одну из двух последних ошибок.

\n

Какие состояния нужны серверной записи

\n

Статус записи должен описывать решение, которое сервер принимает на входе защищённого handler. Для минимальной модели достаточно четырёх состояний: active, rotated, revoked и expired. У записи также есть lineageId, generation, время истечения и ссылка на текущий ID. Сам ID следует генерировать криптографически случайным и не включать в логи целиком.

\n
Состояние записи и ответ защищённого endpoint
СостояниеУсловиеОтветПобочный эффект
active и currentСрок сервера не истёк, поколение совпалоПродолжить к authorizationТолько разрешённое действие
rotatedЕсть successor, но ID больше не current401 с машинной причиной stale-sessionНе выдавать новый successor
revokedLogout или административный отзыв401 с причиной revoked-sessionНе менять ресурс
expiredИстёк серверный срок401 с причиной expired-sessionУдалить запись по политике хранения
\n

Причины отказа полезны для метрик, но не должны раскрывать секрет или лишние сведения анонимному клиенту. Внешний ответ может быть единым 401, а точную причину можно оставить в защищённом журнале с correlation ID. Код 403 оставляют для случая, когда субъект установлен, но authorization запретила действие.

\n

Флаги cookie ограничивают доставку

\n

Secure просит user agent отправлять cookie только по защищённому соединению, но не отзывает серверную запись и не превращает значение в доказательство подлинности. HttpOnly убирает cookie из обычного JavaScript API; это снижает риск чтения значения клиентским кодом, но не лечит XSS и не останавливает браузер от автоматической отправки cookie.

\n

SameSite=Lax ограничивает часть cross-site запросов и обычно допускает верхнеуровневую навигацию безопасным методом. Это слой defense in depth, а не общий CSRF-контракт: GET, который меняет состояние, клиентский CSRF и неконтролируемые поддомены остаются проблемами. Для mutation endpoint задайте CSRF-токен или проверку Origin/Referer по требованиям приложения.

\n

Пара Max-Age/Expires задаёт срок хранения в user agent. Серверный expiresAt — отдельные часы. Браузер может удалить cookie раньше, а сервер обязан отвергнуть запись после своего срока, даже если cookie всё ещё пришла.

\n

Префикс __Host- ограничивает область cookie: нужны Secure, явный Path=/ и отсутствие Domain. Это привязывает cookie к конкретному host, но не является проверкой роли и не защищает от ошибки в серверном handler. Если продукт работает на нескольких поддоменах или в iframe, это решение может быть неприменимо: scope, SameSite=None, CSRF и доверие к соседним host надо проектировать отдельно.

\n
Set-Cookie: __Host-session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store
\n

Строка выше — контракт заголовка, а не готовое значение. Не подставляйте в cookie email, роль или JSON профиля. Приложение само выбирает срок, способ хранения и формат непрозрачного ID.

\n

Rotation должна иметь один победивший переход

\n

Rotation нужна, когда меняется уровень доверия: например, анонимная сессия становится аутентифицированной или меняются привилегии. OWASP рекомендует регенерировать идентификатор после изменения уровня привилегий и считать действующим только current ID. В выбранной модели старая запись получает rotated, создаётся successor с увеличенным поколением, а указатель lineage атомарно переключается на него.

\n
const presented = request.cookies['__Host-session'];
const current = await sessions.findById(presented);

if (!current || current.status !== 'active'
    || current.id !== current.lineageCurrentId
    || current.expiresAt <= now()) {
  return reject401('invalid-session');
}

const next = await sessions.rotateIfCurrent({
  oldId: current.id,
  lineageId: current.lineageId,
  expectedGeneration: current.generation,
});

if (!next) return reject401('stale-session');
response.setHeader('Set-Cookie', serializeSessionCookie(next.id));
\n

Это псевдокод: функции хранилища и сериализации здесь не определены. Ключевое место — rotateIfCurrent. Оно должно в одной транзакции или условном обновлении проверить ожидаемое поколение, пометить старую запись и создать ровно одного successor. Проверка в приложении, отдельная запись и последующий update без условия оставляют гонку.

\n

Два параллельных запроса могут прочитать один active ID. Если оба безусловно создают successor, браузер получит два ответа Set-Cookie, а последним станет случайный. Тест отправляет два запроса одного поколения и проверяет, что победил один, а проигравший получил stale-session или иной заранее оговорённый безопасный результат.

\n

Logout должен назвать область отзыва

\n

Logout-current отзывает предъявленную текущую запись. Logout-lineage отзывает все записи одного входа, включая successor. Logout-all отзывает все записи identity на устройствах. Это три разных операции и три разных ожидания пользователя. Старая вкладка не должна выключать новый вход, если endpoint заявляет logout-current.

\n
Выбор scope для logout
ОперацияЧто отзываетКогда применятьПроверка
currentТекущий IDОбычная кнопка выхода из этой вкладкиНовое поколение остаётся active
lineageЦепочку одного входаПодозрение на кражу этого входаСтарый и новый ID получают reject
allВсе записи identityСмена пароля или аварийный отзывДругие устройства теряют доступ
\n

После серверного отзыва ответ может очистить cookie. Чтобы браузер удалил именно созданную cookie, имя, Path и Domain должны совпадать с исходными атрибутами; для __Host- не добавляйте Domain. Очистка улучшает UX, но logout считается выполненным только после смены серверного статуса.

\n
const presented = request.cookies['__Host-session'];
const result = await sessions.revokeCurrentIfCurrent({
  presentedId: presented,
});

if (result.kind === 'stale') return clearHostCookie(response, 401);
return clearHostCookie(response, 204);
\n

Вызов с rotated ID в этой политике не отзывает successor и не создаёт новую запись. Если бизнесу нужен logout-lineage, это должен быть отдельный endpoint или явный параметр с отдельной авторизацией.

\n

Воспроизводимый интеграционный сценарий

\n

Ниже приведён контрактный сценарий для тестового окружения. Переменная BASE_URL должна указывать на приложение, где реализованы /session/start, /session/rotate, /protected-resource и /session/logout. Названия учебные; они не являются стандартными маршрутами.

\n
export BASE_URL='https://test.example.invalid'
# Сначала создаём тестовую сессию и сохраняем cookie jar.
curl -i -c cookie-jar.txt \"$BASE_URL/session/start\"
export OLD_ID='opaque-id-from-session-start'
# Current ID проходит до authorization.
curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"
# Rotation должна обновить тот же jar.
curl -i -X POST -b cookie-jar.txt -c cookie-jar.txt -H \"Cookie: __Host-session=$OLD_ID\" -H 'Origin: https://test.example.invalid' \"$BASE_URL/session/rotate\"
# Старый ID: ожидаем 401, ресурс не изменился.
curl -i -X POST -H \"Cookie: __Host-session=$OLD_ID\" -H 'Content-Type: application/json' -d '{\"value\":\"must-not-be-written\"}' \"$BASE_URL/protected-resource\"
# Logout-current с новым ID и повторная проверка.
curl -i -X POST -b cookie-jar.txt -c cookie-jar.txt \"$BASE_URL/session/logout\"
curl -i -b cookie-jar.txt \"$BASE_URL/protected-resource\"
\n

Первые два запроса фиксируют happy path, третий — обязательный отрицательный путь: ожидание 401, ресурс не изменился. Проверяйте также единственный successor и атрибуты Set-Cookie. Для реального запуска сохраните cookie jar только во временном каталоге CI. Значение test.example.invalid намеренно не является рабочим доменом: его заменяет владелец стенда.

\n

Диагностика по симптому

\n
От наблюдаемого сбоя к проверяемому действию
СимптомГипотезаКак проверитьИсправление
Cookie есть, ответ 401record revoked, rotated или expiredСопоставить хеш ID, status, generation и срокОставить отказ и корректно очистить cookie
Старый запрос изменил данныеПроверено только наличие IDПовторить запрос после rotationПроверять record до вызова domain service
Старая вкладка выключила новуюLogout отозвал lineage вместо currentСравнить scope и lineage в событииРазделить операции отзыва
После двух rotation активны два IDНет условного перехода поколенияПараллельно отправить запросы одного generationДобавить транзакцию или compare-and-swap
Cross-site mutation прошлаSameSite приняли за CSRF-защитуПроверить Origin, метод и CSRF-токенЗакрыть mutation отдельным серверным контролем
\n

Что логировать без утечки секрета

\n

Для расследования нужны не значения cookie, а связи между событиями. Логируйте correlation ID, хешированный или усечённый идентификатор, lineage ID, старое и новое поколение, результат перехода и машинную причину отказа. Не пишите в лог полный session ID, заголовок Cookie или содержимое профиля.

\n

Минимальная последовательность событий выглядит так: session.presented → session.lookup → session.transition → authorization.decision → protected.effect. Наличие последнего события при transition=stale — повод искать обход проверки или неправильный порядок вызовов. Лог должен позволять связать два параллельных запроса, но не давать материал для повторного входа.

\n

Пределы применимости модели

\n

Строгий reject старого ID подходит для обычной веб-сессии, если параллельные запросы не требуют grace period. Поток с длинной загрузкой, несколькими устройствами, offline-клиентом или несколькими BFF может потребовать иной политики. Тогда задайте срок и число повторных использований явно, привяжите их к операции и всё равно запрещайте старому ID выполнять неожиданные побочные эффекты.

\n

Эта статья не задаёт алгоритм CSRF, CORS, reauthentication, распределённых блокировок, clock skew, хранения в конкретной БД или поведение reverse proxy. Browser test не доказывает атомарность базы; unit test хранилища не доказывает, что браузер применил нужные Path, Domain и SameSite. Эти свойства проверяются отдельными тестами на том стеке, который вы выпускаете.

\n

NIST описывает жизненный цикл сессии и повторную аутентификацию, но не выбирает за проект схему таблиц. RFC описывает cookie-протокол, но не знает, какое действие разрешено субъекту. OWASP формулирует прикладные рекомендации, но их надо сопоставить с вашими браузерами, доменами и threat model. Официальная ссылка подтверждает факт или рекомендацию, а не готовность конкретного сервиса.

\n

Порядок проверки перед интеграцией

\n
  1. Назовите защищённый handler и эффект, который он может изменить.
  2. Зафиксируйте владельца каждого слоя: browser delivery, server record, lineage и authorization.
  3. Опишите состояния active, rotated, revoked и expired и ответ для каждого.
  4. Проверьте current ID до authorization и убедитесь, что отказ не вызывает domain effect.
  5. Проверьте rotation двумя параллельными запросами одного поколения: successor должен быть один.
  6. Проверьте старый ID после rotation: он получает reject и не меняет ресурс.
  7. Проверьте выбранный logout scope; для logout-current старый logout не должен отзывать новую сессию.
  8. Проверьте фактический Set-Cookie и очистку в браузере или интеграционном стенде.
  9. Проверьте mutation с чужим Origin и без CSRF-доказательства.
  10. Сопоставьте события по correlation ID и убедитесь, что секреты не попадают в логи.
\n

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

" } diff --git a/editorial/agent-rewrites/177.json b/editorial/agent-rewrites/177.json index 6b7cfcb..bcb6589 100644 --- a/editorial/agent-rewrites/177.json +++ b/editorial/agent-rewrites/177.json @@ -2,6 +2,6 @@ "index": 177, "slug": "editorial-2023-02-practice-sessions-auth", "title": "Сессия после rotation и logout: один контракт для cookie и сервера", - "excerpt": "Как отделить cookie от серверной сессии, не принять старый ID после rotation и доказать logout проверкой доступа, а не только очисткой браузера.", - "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс получает успешный ответ, хотя сессию уже должны были закрыть. В другом варианте старый запрос приходит после rotation и снова проходит, потому что обработчик проверяет только наличие cookie. Ошибка выглядит случайной. Цена ошибки измерима: сервер меняет данные после отзыва доступа, новая сессия может исчезнуть из-за позднего ответа, а расследование не знает, какой ID считался действующим.

\\\n

Тезис простой: cookie переносит непрозрачный ID, но не принимает решение о доступе. Сервер хранит запись сессии, её статус, срок и связь с текущей версией. Rotation заменяет current ID и делает старый ID непригодным. Logout отзывает серверную запись и отправляет браузеру cookie с тем же scope в прошлом. Эти действия связаны, но не заменяют друг друга.

\\\n

Разделите четыре разных факта

\\\n

Аутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.

\\\n

Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.

\\\n

Второй факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.

\\\n

Третий факт — lineage. Это связь последовательных версий одной сессии. Она отвечает на вопрос «какой ID сейчас current». После rotation у линии должен остаться один current ID. Старый ID можно сохранить для диагностики, но нельзя снова сделать его действующим.

\\\n

Четвёртый факт — право на операцию. Active session не означает право менять профиль, выплачивать деньги или читать административные данные. Authorization отдельно проверяет subject, ресурс и действие. Наличие cookie не даёт ни одной из этих гарантий.

\\\n
Границы контракта одной сессии
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок, имя и scopeЧто ID действителен
Session recordПринимать ли ID?status, expiry и current pointerПраво на конкретное действие
LineageКакой ID заменил старый?generation и successorЧто два запроса выполнятся по порядку
AuthorizationРазрешена ли операция?роль, ресурс и действиеЧто cookie настроена безопасно
\\\n

Cookie flags не являются авторизацией

\\\n

Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.

\\\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.

\\\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».

\\\n

Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.

\\\n
\"Жизненный
Учебная схема разделяет серверный переход active → rotated → revoked и доставку нового или очищенного cookie. Это не trace браузера и не доказательство поведения конкретного приложения.
\\\n

Rotation должен менять current ID атомарно

\\\n

Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.

\\\n

Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.

\\\n

В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.

\\\n
const current = sessions.findById(request.cookies[SESSION_NAME]);\\\n\\\nif (!current || current.status !== 'active' || current.expiresAt <= now) {\\\n  return response.status(401).end();\\\n}\\\n\\\nconst successor = rotateOnce(current); // transaction or compare-and-swap\\\nsetCookie(response, SESSION_NAME, successor.id, {\\\n  secure: true,\\\n  httpOnly: true,\\\n  sameSite: 'lax',\\\n  path: '/',\\\n});\\\n\\\n// A later request with current.id must return stale-session.\\\nreturn response.json({ status: 'rotated' });
\\\n

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

\\\n

Logout состоит из двух действий

\\\n

Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.

\\\n

Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, host, Path и, если он был, Domain. Дата истечения должна быть в прошлом. Совпадение scope важно: clear cookie с другим Path может оставить исходное значение.

\\\n

Эти ветви доказывают разное. Clear cookie улучшает состояние браузера и интерфейс. Только server-side reject доказывает, что отозванный ID больше не принимают. Если logout защищён от CSRF, это отдельная проверка. SameSite не заменяет её во всех потоках.

\\\n

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

\\\n
Диагностика рассинхронизации сессии
СимптомПричинаПроверкаДействие
Старый ID проходит после rotationHandler проверяет наличие cookie, а не status и currentОтправить predecessor после успешного rotationОтклонять rotated ID до protected effect
Logout меняет только интерфейсУдалили cookie, но не отозвали записьПовторить запрос с сохранённым IDRevoke-ить запись и проверить ответ 401/403
Поздний logout закрывает новую сессиюОперация не различает stale и currentСначала сделать rotation, затем logout старым IDВыбрать scope logout-current, lineage или all и закрепить его
Cookie живёт не там, где её чистятПри issue и clear различаются Path, Domain или hostСравнить оба Set-Cookie по каждому атрибутуСформировать clear из того же scope-контракта
Два запроса создают два successorRotation разделён между чтением и записьюПроверить concurrent path в разрешённой средеДобавить транзакционную или conditional линейзацию
\\\n

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

\\\n
  1. Назовите один поток: issue, protected request, rotation или logout. Зафиксируйте identity, session ID, status, expiry и current pointer.
  2. Снимите наблюдаемое поведение до исправления. Не называйте проблему инцидентом без запроса, ответа и безопасного идентификатора корреляции.
  3. Проверьте cookie delivery отдельно: имя, Secure, HttpOnly, SameSite, Path, Domain и срок хранения. Запишите, какой вопрос каждый flag не решает.
  4. Проверьте server record до действия: неизвестный, expired, rotated и revoked ID должны идти по отказному пути.
  5. Проверьте rotation на predecessor и successor. После перехода должен существовать один current ID, а старый не должен продлеваться.
  6. Проверьте logout текущим и stale ID. Current закрывает выбранный scope. Stale не выключает successor, если политика этого не требует.
  7. Проверьте authorization после session validation. Active session должна давать только явно разрешённые действия.
  8. Проверьте гонку и ошибку хранилища в разрешённой интеграционной среде. Учебный пример не заменяет этот сценарий.
\\\n

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

\\\n

Модель не выбирает SQL, кеш, signed token или framework store. Она не моделирует несколько устройств, вкладки, clock skew, reverse proxy, reauthentication, CSRF-токены и сетевой порядок ответов. Идентификаторы в примере учебные. Их нельзя использовать в production. Для logout всех устройств нужна отдельная операция, которая явно выбирает identity и все её записи.

\\\n

Работа готова, если можно показать четыре независимых доказательства: запрос с revoked или rotated ID получает отказ; rotation оставляет ровно один current ID; logout очищает cookie с тем же scope; active session без подходящего authorization получает отказ. Для конкурентного rotation дополнительно нужен тест, который не допускает двух successor. Если доказательство есть только в UI или только в памяти, контракт ещё не проверен.

\\\n

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

\\\n" + "excerpt": "Как отделить cookie от серверной сессии, не принять старый ID после rotation и доказать logout повторным запросом, а не только исчезновением записи в браузере.", + "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успех, хотя доступ уже должен быть отозван. В другом варианте старый запрос приходит после rotation и проходит, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии из-за позднего ответа и расследование, в котором непонятно, какой ID считался текущим.

\\\n

Решение начинается с одного разделения: cookie переносит значение, а сервер принимает решение. Сессия должна иметь запись со статусом, сроком, identity и указателем на текущую версию. Rotation заменяет current ID и делает predecessor непригодным. Logout отзывает серверное состояние и отдельно очищает cookie с тем же scope. Очистка браузера без отказа сервера не является logout.

\\\n

Разделите четыре разных факта

\\\n

Аутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.

\\\n

Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.

\\\n

Второй факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.

\\\n

Третий факт — lineage. Это связь последовательных версий одной сессии. Она отвечает на вопрос «какой ID сейчас current». После rotation у линии должен остаться один current ID. Старый ID можно сохранить для диагностики, но нельзя снова сделать его действующим.

\\\n

Четвёртый факт — право на операцию. Active session не означает право менять профиль, выплачивать деньги или читать административные данные. Authorization отдельно проверяет subject, ресурс и действие. Наличие cookie не даёт ни одной из этих гарантий.

\\\n
Границы контракта одной сессии
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок, имя и scopeЧто ID действителен
Session recordПринимать ли ID?status, expiry и current pointerПраво на конкретное действие
LineageКакой ID заменил старый?generation и successorЧто два запроса выполнятся по порядку
AuthorizationРазрешена ли операция?роль, ресурс и действиеЧто cookie настроена безопасно
\\\n

Cookie flags не являются авторизацией

\\\n

Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.

\\\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.

\\\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».

\\\n

Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.

\\\n
\"Жизненный
Учебная схема разделяет серверный переход active → rotated → revoked и доставку нового или очищенного cookie. Это не trace браузера и не доказательство поведения конкретного приложения.
\\\n

Rotation должен менять current ID атомарно

\\\n

Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.

\\\n

Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.

\\\n

В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.

\\\n
const current = sessions.findById(request.cookies[SESSION_NAME]);\\\n\\\nif (!current || current.status !== 'active' || current.expiresAt <= now) {\\\n  return response.status(401).end();\\\n}\\\n\\\nconst successor = rotateOnce(current); // transaction or compare-and-swap\\\nsetCookie(response, SESSION_NAME, successor.id, {\\\n  secure: true,\\\n  httpOnly: true,\\\n  sameSite: 'lax',\\\n  path: '/',\\\n});\\\n\\\n// A later request with current.id must return stale-session.\\\nreturn response.json({ status: 'rotated' });
\\\n

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

\\\n

Logout состоит из двух действий

\\\n

Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.

\\\n

Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, host, Path и, если он был, Domain. Дата истечения должна быть в прошлом. Совпадение scope важно: clear cookie с другим Path может оставить исходное значение.

\\\n

Эти ветви доказывают разное. Clear cookie улучшает состояние браузера и интерфейс. Только server-side reject доказывает, что отозванный ID больше не принимают. Если logout защищён от CSRF, это отдельная проверка. SameSite не заменяет её во всех потоках.

Проверять нужно не только финальный экран, но и HTTP-ответ. Сохраните заголовки без секретов и сравните scope cookie при issue и clear. Последовательность ниже использует учебные ID; адрес и endpoint должны существовать в вашей тестовой среде.

# predecessor после rotation должен получить отказ\ncurl -i -H 'Cookie: sid=fixture-s-1' -X POST 'https://example.test/profile'\n\n# successor проходит проверку сессии, затем authorization\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/profile'\n\n# logout-current отзывает successor и возвращает Set-Cookie с датой в прошлом\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/logout'\n\n# тот же ID после logout снова должен получить отказ\ncurl -i -H 'Cookie: sid=fixture-s-2' -X POST 'https://example.test/profile'
\\\n

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

\\\n
Диагностика рассинхронизации сессии
СимптомПричинаПроверкаДействие
Старый ID проходит после rotationHandler проверяет наличие cookie, а не status и currentОтправить predecessor после успешного rotationОтклонять rotated ID до protected effect
Logout меняет только интерфейсУдалили cookie, но не отозвали записьПовторить запрос с сохранённым IDRevoke-ить запись и проверить ответ 401/403
Поздний logout закрывает новую сессиюОперация не различает stale и currentСначала сделать rotation, затем logout старым IDВыбрать scope logout-current, lineage или all и закрепить его
Cookie живёт не там, где её чистятПри issue и clear различаются Path, Domain или hostСравнить оба Set-Cookie по каждому атрибутуСформировать clear из того же scope-контракта
Два запроса создают два successorRotation разделён между чтением и записьюПроверить concurrent path в разрешённой средеДобавить транзакционную или conditional линейзацию
\\\n

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

\\\n
  1. Назовите один поток: issue, protected request, rotation или logout. Зафиксируйте identity, session ID, status, expiry и current pointer.
  2. Снимите наблюдаемое поведение до исправления. Не называйте проблему инцидентом без запроса, ответа и безопасного идентификатора корреляции.
  3. Проверьте cookie delivery отдельно: имя, Secure, HttpOnly, SameSite, Path, Domain и срок хранения. Запишите, какой вопрос каждый flag не решает.
  4. Проверьте server record до действия: неизвестный, expired, rotated и revoked ID должны идти по отказному пути.
  5. Проверьте rotation на predecessor и successor. После перехода должен существовать один current ID, а старый не должен продлеваться.
  6. Проверьте logout текущим и stale ID. Current закрывает выбранный scope. Stale не выключает successor, если политика этого не требует.
  7. Проверьте authorization после session validation. Active session должна давать только явно разрешённые действия.
  8. Проверьте гонку и ошибку хранилища в разрешённой интеграционной среде. Учебный пример не заменяет этот сценарий.
\\\n

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

\\\n

Модель не выбирает SQL, кеш, signed token или framework store. Она не моделирует несколько устройств, вкладки, clock skew, reverse proxy, reauthentication, CSRF-токены и сетевой порядок ответов. Идентификаторы в примере учебные. Их нельзя использовать в production. Для logout всех устройств нужна отдельная операция, которая явно выбирает identity и все её записи.

\\\n

Работа готова, если можно показать четыре независимых доказательства: запрос с revoked или rotated ID получает отказ; rotation оставляет ровно один current ID; logout очищает cookie с тем же scope; active session без подходящего authorization получает отказ. Для конкурентного rotation дополнительно нужен тест, который не допускает двух successor. Если доказательство есть только в UI или только в памяти, контракт ещё не проверен.

\\\n

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

" } diff --git a/editorial/agent-rewrites/178.json b/editorial/agent-rewrites/178.json index e5bef67..654747b 100644 --- a/editorial/agent-rewrites/178.json +++ b/editorial/agent-rewrites/178.json @@ -3,4 +3,5 @@ "slug": "editorial-2023-01-field-threat-model", "title": "Модель угроз для одного потока: от симптома до проверяемого контроля", "excerpt": "Как разобрать security-sensitive изменение, когда команда предлагает контроль, но не называет актив, границу и злоупотребление. На примере одного API-потока — схема, код, таблица диагностики и критерий готовности.", - "contentHtml": "

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

\n

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

\n

Начните с наблюдаемого симптома

\n

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

\n

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

\n

Для первого прохода достаточно одного потока. Возьмём учебный пример: browser-client отправляет запрос в public-api-to-handler, handler записывает change-request в хранилище. Asset — заявка на изменение. Boundary — место, где публичный вход становится внутренним вызовом обработчика. Abuse — неподтверждённый запрос пытается изменить заявку. Это условная модель. Она не описывает конкретный продукт и не доказывает безопасность production-системы.

\n
browser-client\n      |\n      | request\n      v\n public-api-to-handler   <-- boundary\n      |\n      v\n    handler ----> change-request store (asset)\n\nabuse: неподтверждённый запрос меняет заявку\ncontrol: обработчик отклоняет вход без проверяемого подтверждения\nevidence: тест показывает отказ такого входа
\n

Схема полезна только тогда, когда каждый элемент ведёт к вопросу. Asset отвечает, какое свойство нельзя потерять. Boundary показывает, где меняются предположения о доверии. Abuse описывает действие нарушителя. Control формулирует решение. Evidence показывает, что решение проверили. Слово «система» не заменяет ни один из этих элементов.

\n

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

\n
Диагностика неполной модели угроз
СимптомПричинаПроверкаДействие
Назван control, но нет assetВыбрали привычный механизмЧто потеряет свойство при злоупотреблении?Назвать один актив и его свойство
Есть threat, но нет boundaryНе указано место защитного решенияГде вход перестаёт быть доверенным?Поставить границу на DFD и описать переход
Есть control, но нет отрицательной веткиПроверяли только успешный путьКакой вход обязан получить отказ?Добавить тест на отказ и ожидаемый результат
В evidence написано «проверено»Не назван метод и артефактЧто увидит независимый проверяющий?Указать test, log, trace или ручной шаг
Rollback означает «вернуть всё»Модель смешана с поставкойКакие файлы, права и данные меняются?Разделить исходный snapshot и release-план
\n

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

\n

Сначала граница, потом механизм

\n

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

\n

В примере ниже signed-request — лишь учебная гипотеза. Её обещание узкое: handler принимает запрос только после проверяемого подтверждения. Это не синоним шифрования транспорта, аутентификации пользователя или авторизации операции. В настоящем API могут потребоваться другой протокол, nonce, защита от повторной отправки, проверка полномочий и журналирование. Статья не выбирает их за владельца системы.

\n
\"Маршрут
Маршрут вопросов для одного потока. Рисунок показывает порядок диагностики, а не карту реального продукта, отчёт сканера или план развёртывания.
\n

Проверьте минимальный контракт кодом

\n

Маленькая функция может поймать механические пропуски до обсуждения реализации. Она принимает actor, asset, boundary, abuse и один из заранее названных типов контроля. Для принятой записи возвращает evidence и snapshot для учебного отката. Такой код проверяет только структуру модели. Он не ходит в сеть, не проверяет ключ, не вызывает API и не оценивает риск.

\n
const plan = planTeachingThreatModel({\n  actor: 'browser-client',\n  asset: 'change-request',\n  boundary: 'public-api-to-handler',\n  abuse: 'unsigned request changes a request',\n  control: 'signed-request'\n});\n\nif (!plan.accepted) throw new Error(plan.reason);\nif (plan.evidence.length !== 1) throw new Error('missing evidence');\nif (plan.rollback.snapshot.asset !== 'change-request') {\n  throw new Error('rollback snapshot is incomplete');\n}
\n

В этом фрагменте есть намеренный отрицательный путь. Пустой asset, неизвестный control или отсутствие boundary должны вернуть отказ, а не «почти принятую» модель. Название функции и ответ отражают учебный контракт. Не переносите его в production без отдельной проверки требований, реализации, секретов, прав и совместимости.

\n

После успешной проверки контракта найдите реальную точку потока. Сопоставьте имя asset с полем документации или схемы, boundary — с middleware, gateway или handler, а evidence — с конкретным тестом или журналом. Если сопоставление не получается, локальный PASS ничего не говорит о приложении. Он только говорит, что четыре строки заполнены.

\n

Порядок действий

\n
  1. Зафиксируйте симптом одной фразой: какой вход, какой обработчик и какое решение сейчас не имеют проверяемого условия.
  2. Назовите один asset и свойство, которое нужно сохранить. Не используйте «данные» или «безопасность» без уточнения.
  3. Опишите actor и abuse как действие. Например: внешний клиент повторяет запрос и меняет чужую заявку.
  4. Нарисуйте границу потока и назначьте владельца решения на каждой стороне. Проверьте, не дублируют ли два слоя одну и ту же проверку.
  5. Сформулируйте control как наблюдаемое поведение отказа. «Используем подпись» слабее, чем «неподтверждённый запрос получает отказ до записи».
  6. Прогоните минимальный контракт на полном и неполном входе. Сохраните результат и причину отказа.
  7. Добавьте проверку реализации с явными данными, окружением и ожидаемым результатом. Отдельно укажите, что тест не проверяет.
  8. Подготовьте rollback до выпуска. Для модели сохраните snapshot; для продукта опишите обратимые изменения, совместимость, права, ключи и наблюдение.
  9. Попросите независимого участника воспроизвести проверку по записи. Если ему приходится угадывать вход или критерий PASS, change не готов.
\n

Когда путь нужно остановить

\n

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

\n

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

\n

Не называйте локальную функцию security review, pentest или compliance evidence. Она не видит production, реальные роли, конфигурацию, ротацию ключей, повторную отправку, лимиты и операционные журналы. Учебный пример помогает проверить форму рассуждения. Он не заменяет анализ системы и согласование риска.

\n

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

\n

Один поток готов к следующему этапу, когда независимый проверяющий по записи может назвать asset, actor, boundary и abuse; найти контроль в конкретном месте; запустить отрицательную проверку; увидеть ожидаемый отказ до изменения asset; определить сохранённый snapshot и условия отката. Если хотя бы один пункт требует устного пояснения автора, модель ещё не завершена.

\n

Критерий не означает «угроз больше нет». Он означает, что команда понимает выбранный риск, границу утверждения и следующий реальный тест. Результат может быть «контроль не выбран», «проверка не выполнена» или «нужен владелец». Это корректные исходы. Они лучше фиктивного PASS, который не связан с поведением приложения.

\n

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

\n"} \ No newline at end of file + "contentHtml": "

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

\n

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

\n

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

\n

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

\n

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

\n

Для учебного потока зададим следующие значения: browser-client отправляет запрос в public-api, обработчик проверяет вход и пишет change-request в хранилище. Актив — не абстрактные «данные», а состояние конкретной заявки и её целостность. Актор — внешний клиент, который может сформировать запрос. Злоупотребление — попытка изменить заявку без подтверждения и без права на эту операцию.

\n
browser-client\n      |\n      | POST /v1/change-requests/42\n      v\n public-api <-- trust boundary: public input / handler decision\n      |\n      v\n   handler ----> change-request store (asset: integrity)\n\nabuse: внешний запрос меняет чужую заявку\ncontrol: проверка подлинности и полномочий до записи\nevidence: отрицательный тест показывает отказ и неизменное состояние
\n

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

\n

Разложите поток на четыре вопроса

\n

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

\n
Минимальная запись для одного изменения
ПолеВопросПримерЧто проверять
AssetЧто нельзя потерять?Целостность заявки 42Какое состояние меняется и кто им владеет
ActorКто формирует вход?Внешний клиентКакие у него права и какие данные ему доступны
BoundaryГде принимается решение?Вход из public-api в handlerНа каком слое проверка обязательна и кто её владелец
AbuseКакое действие нужно остановить?Запись чужого измененияКакие вход и состояние воспроизводят сценарий
EvidenceЧто покажет результат?403 до записи, состояние не изменилосьТочный тест, код ответа, событие и состояние после запроса
\n

Таблица не требует выбрать STRIDE, PASTA или другой метод. OWASP прямо указывает, что его проект не задаёт единственную методику: подход выбирают по контексту, целям приватности и безопасности, зрелости команды и ограничениям поставки. Поэтому в статье фиксируется малый контракт потока, а не объявляется универсальный стандарт.

\n

Поставьте границу там, где меняется доверие

\n

Граница доверия — не обязательно сетевой экран. Это место, где меняются предположения о входе или появляется новое право на действие. В нашем потоке public-api получает внешние данные, а handler решает, можно ли менять актив. Если gateway уже проверяет токен, это нужно записать отдельно: gateway подтверждает одно свойство, handler может отвечать за другое.

\n

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

\n
\"Схема
Последовательность вопросов для одного потока. Диаграмма показывает границы учебной записи; она не является схемой реальной сети, отчётом сканера или доказательством внедрения контроля.
\n

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

\n

Сформулируйте контроль как поведение

\n

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

\n

Какая именно технология реализует проверку, зависит от архитектуры. Для браузерного запроса могут понадобиться управление сессией, защита от CSRF (межсайтовой подделки запроса) и авторизация операции. Для webhook от сервиса-партнёра — проверка подписи тела, ограничения времени и защита от повторной доставки. Для внутреннего вызова — отдельная идентичность сервиса и политика доступа. Нельзя выбрать один из этих вариантов только по слову «API».

\n

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

\n

Запустите минимальную фикстуру

\n

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

\n
node --input-type=module <<'NODE'\nconst controls = new Set(['signed-request', 'session-and-authorization', 'service-identity']);\n\nfunction checkModel(input) {\n  const required = ['actor', 'asset', 'boundary', 'abuse', 'control'];\n  const missing = required.filter((field) => !input[field]);\n  if (missing.length) return { accepted: false, reason: "missing: " + missing.join(", ") };\n  if (!controls.has(input.control)) return { accepted: false, reason: 'unknown control' };\n\n  return {\n    accepted: true,\n    claim: "reject " + input.abuse + " before writing " + input.asset,\n    evidence: ["negative test at " + input.boundary + ": refusal before write"],\n    rollback: { asset: input.asset, state: 'unchanged snapshot' }\n  };\n}\n\nconst cases = [\n  { actor: 'browser-client', asset: 'change-request:42', boundary: 'public-api->handler',\n    abuse: 'unsigned request changes a request', control: 'signed-request' },\n  { actor: 'browser-client', asset: '', boundary: 'public-api->handler',\n    abuse: 'unsigned request changes a request', control: 'signed-request' },\n  { actor: 'browser-client', asset: 'change-request:42', boundary: 'public-api->handler',\n    abuse: 'unsigned request changes a request', control: 'encryption' }\n];\n\nconst results = cases.map(checkModel);\nif (!results[0].accepted || results[0].evidence.length !== 1) throw new Error('valid case failed');\nif (results[1].accepted || results[2].accepted) throw new Error('invalid case accepted');\nconsole.log(results);\nNODE
\n

Ожидаемый результат — один принятый объект и два отказа с причинами missing: asset и unknown control. Этот результат подтверждает только структуру рассуждения. Он не подтверждает, что endpoint проверяет подпись, что идентификатор пользователя нельзя подменить или что хранилище не изменится при реальном запросе.

\n

Проверьте реальное поведение отдельно

\n

После фикстуры переходите к приложению. Подготовьте тестовую запись, для которой можно безопасно сравнить состояние до и после. Укажите в тесте идентификатор субъекта, заявки и права; не берите production-секреты и реальные персональные данные. Сначала отправьте допустимый запрос, затем тот же запрос с удалённым подтверждением или чужим идентификатором. Нужны не только ответ и лог, но и проверка, что запись не изменилась.

\n
BASE_URL='http://127.0.0.1:3000'\nREQUEST_ID=42\n\ncurl -i --fail-with-body -X POST \\\n  "$BASE_URL/v1/change-requests/$REQUEST_ID" \\\n  -H 'content-type: application/json' \\\n  -H 'x-test-actor: user-42' \\\n  -d '{"status":"approved"}'\n\n# Отрицательный сценарий: тестовый стенд должен вернуть 401/403,\n# а GET после него — прежнюю версию заявки.\ncurl -i -X POST \\\n  "$BASE_URL/v1/change-requests/$REQUEST_ID" \\\n  -H 'content-type: application/json' \\\n  -H 'x-test-actor: user-without-right' \\\n  -d '{"status":"approved"}'
\n

Адрес 127.0.0.1:3000 — заменяемый адрес локального стенда, а заголовок x-test-actor — условный интерфейс тестового приложения, не стандарт безопасности. Если сервис использует cookie, OAuth или mTLS, тест должен применять его настоящий механизм в изолированной среде. Код ответа 401 или 403 выбирается контрактом API; нельзя считать любой отказ доказательством, пока состояние и аудит операции не проверены.

\n

Отделите результат от обещания

\n

У теста должны быть явные precondition, действие и postcondition. До запроса: заявка принадлежит пользователю A, версия равна 7, actor не имеет права на заявку B. Действие: actor отправляет запрос изменения B. После: сервис возвращает отказ, версия и поля B не меняются, событие отказа содержит безопасный идентификатор операции. Такой набор проверяет поведение, а не наличие строчки с названием middleware.

\n

Лог тоже не равен доказательству. Запись «authorization failed» полезна, если по ней можно найти запрос, слой и причину, но без раскрытия токена или персональных данных. Трассировка и метрики помогают увидеть путь, однако их отсутствие в учебной фикстуре ожидаемо. Называйте конкретный артефакт: тест, ответ, снимок состояния, событие или ручной шаг.

\n

Заранее зафиксируйте границы: тестовый стенд не показывает поведение всех прокси; один отрицательный actor не покрывает матрицу ролей; отсутствие записи в локальном журнале не доказывает отсутствие записи в очереди; проверка подписи не подтверждает защиту от повторной отправки. Эти ограничения превращают «PASS» в честный результат с понятным следующим тестом.

\n

Порядок работы

\n
  1. Запишите симптом: какой вход, какой обработчик и какое условие отказа сейчас не видны.
  2. Назовите один актив и защищаемое свойство: целостность, конфиденциальность, доступность или право выполнить действие.
  3. Опишите актора и злоупотребление как действие, а не как ярлык вроде «атака».
  4. Поставьте границу на схеме потока и назначьте владельца каждой проверки.
  5. Сформулируйте контроль через наблюдаемое поведение до изменения актива.
  6. Добавьте положительный и отрицательный сценарии с точным входом, кодом ответа и postcondition.
  7. Запустите локальную фикстуру, чтобы отсеять пустые поля и неизвестные варианты контроля.
  8. Повторите сценарий на изолированном стенде настоящего приложения и проверьте состояние, журнал или трассу.
  9. Сохраните snapshot и опишите откат для кода, схемы данных, прав и ключей; не ограничивайтесь откатом записи модели.
  10. Попросите независимого участника воспроизвести путь. Если он угадывает вход, владельца или критерий отказа, change не готов.
\n

Когда нужно остановиться

\n

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

\n

Не объявляйте контроль готовым по одному успешному запросу. Он показывает доступный путь, но не показывает отказ злоупотребления. Не называйте учебную функцию security review, pentest, аттестацией или compliance evidence: она не видит реальные роли, конфигурацию, ротацию ключей, повторную доставку, лимиты, очереди и операционные журналы.

\n

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

\n

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

\n

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

\n

Критерий не означает, что угроз больше нет. Он означает, что команда понимает выбранный риск и знает, какое утверждение подтверждено. Корректный исход может быть «контроль не выбран», «проверка не выполнена» или «нужен владелец». Такой результат полезнее фиктивного PASS, не связанного с поведением приложения.

\n

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

\n" +}