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.
Главный тезис простой: CORS и CSRF работают на разных границах. CORS ограничивает доступ браузерного кода к cross-origin response. CSRF защищает state-changing запрос, который использует учетные данные пользователя, например cookie сессии. Preflight проверяет форму запроса. Он не подтверждает token, пользователя или право на объект.
\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 совпал, пользователь всё ещё может не иметь права на конкретную запись.
Origin. Браузер сравнивает tuple из scheme, host и port. https://app.example.test и http://app.example.test имеют разные origins. Порт 8443 тоже меняет tuple. Path, query и fragment в origin не входят. Поэтому проверка вида host.endsWith('example.test') слишком широка: она превращает любой поддомен в доверенный источник.
Origin — это техническая граница браузера, а не готовая модель бизнес-доверия. Даже точный https://admin.example.test не должен автоматически получать права user API. Его добавляют в allow-list только для конкретной причины, endpoint и набора данных.
CORS. Сервер сообщает браузеру, какому source origin можно отдать response браузерному коду. Для credentialed response нужен точный Access-Control-Allow-Origin и Access-Control-Allow-Credentials: true. Значение * нельзя совмещать с запросом, который использует credentials. Это правило отвечает за видимость представления ответа. Оно не авторизует mutation и не проверяет CSRF token.
CSRF. Cookie отправляется браузером автоматически по своим правилам. Сам факт наличия cookie не доказывает, что пользователь намеренно вызвал действие из доверенного интерфейса. Сервер должен проверить token, строгую origin policy или другой подходящий proof до побочного эффекта. После этого он отдельно проверяет authorization: имеет ли actor право выполнить действие над данным объектом.
\nPreflight. Браузер отправляет OPTIONS, когда форма cross-origin запроса выходит за CORS safelist. PATCH, JSON content type и custom header часто приводят к preflight. Успешный OPTIONS разрешает форму следующего запроса для указанного origin. Он не сравнивает CSRF token с сессией и не проверяет бизнес-права. Обычный form-shaped POST может не иметь preflight, хотя меняет состояние. Поэтому CSRF нельзя строить на предположении, что опасный запрос обязательно виден как OPTIONS.
\nРассмотрим cookie-аутентифицированный endpoint PATCH /profile. Клиент живет на разрешенном origin и передает token в custom header. Учебный сервер сначала проверяет origin и token, затем право пользователя. Код показывает порядок условий, но не заменяет middleware, браузерный тест и проверку cookie attributes.
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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| JavaScript не читает response | Origin не разрешен или заголовок не совпал | Сверить полный 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 получает 403 | CSRF 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 |
Атрибуты cookie, SameSite policy, third-party cookie restrictions, redirects, proxy cache и режим приватности браузера влияют на фактическую доставку credentials. Поэтому нельзя заключить из одного response header, что cookie была отправлена. Нельзя и заключить из отсутствия OPTIONS, что запрос безопасен: form submission и некоторые safelisted shapes способны менять состояние.
\nCSRF не защищает от 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.
\nEndpoint готов к изменению, если reviewer может назвать разрешенный source origin, увидеть exact credentialed CORS contract, воспроизвести нужный preflight или доказать его отсутствие, получить отказ без CSRF proof и отдельно подтвердить permission check. В тестовой среде server log показывает, что отрицательные ветки не вызвали side effect. Если есть только CORS header, работа не готова. Если есть только unit test token, не доказана интеграция с браузером. Если есть только ручной happy path, не защищен отрицательный путь.
\nOrigin.После переноса интерфейса на новый origin команда видит в консоли CORS error. Один запрос «не читается» из JavaScript, другой получает 403, а третья операция, судя по журналу API, всё-таки меняет данные. Эти симптомы легко свести к одной настройке и открыть Access-Control-Allow-Origin: * или отключить CSRF-проверку. Так можно одновременно разрешить лишнему сайту читать ответы и пропустить изменение, отправленное браузером с чужой страницы.
Правильная диагностика начинается с разделения вопросов. Origin определяет контекст страницы. CORS сообщает браузеру, можно ли коду этой страницы прочитать cross-origin response. Preflight проверяет, разрешена ли форма будущего запроса. CSRF-защита решает на сервере, есть ли у state-changing запроса доказательство доверенного намерения. Ни один из этих слоёв не заменяет authentication и authorization.
\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 прошёл, право менять конкретный профиль всё ещё нужно проверять отдельно.
В модели 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.
Из этого не следует, что любой поддомен безопасен. Проверка вроде host.endsWith('.example.test') расширяет доверие на каждый хост под доменом, включая забытый в DNS или отданный внешнему провайдеру. Для credentialed API храните явный allow-list полных origin и сравнивайте сериализованное значение целиком. https://admin.example.test может быть разрешённым источником для одного ресурса, но это не даёт его коду бизнес-права на любой объект.
CORS — протокол обмена между user agent и сервером. Сервер возвращает Access-Control-Allow-Origin, а браузер использует его при решении, можно ли отдать response коду страницы. Это не сетевой firewall: серверный endpoint может получить простой cross-origin запрос даже тогда, когда JavaScript не сможет прочитать ответ. Поэтому CORS-заголовок нельзя использовать как единственную защиту операции.
Для запроса с 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 не отдал ответ, подготовленный для другого источника.
Важна и обратная сторона: response с ошибкой тоже может быть скрыт от JavaScript. Поэтому «в консоли CORS error» не доказывает, что сервер не выполнил side effect. Сверяйте browser Network, status на API boundary и запись в журнале операции.
\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.
Preflight не содержит cookie сессии: в Fetch Standard credentials mode для него — same-origin. Он не сравнивает CSRF token, не знает пользователя и не проверяет право на профиль. Более того, обычная HTML-форма может отправить простой POST без preflight. Значит, отсутствие строки OPTIONS в логах не означает отсутствие CSRF-риска.
Практический контракт для нашего endpoint выглядит узко: разрешить только https://app.example.test, method PATCH и headers content-type, x-csrf-token. Не следует отвечать «разрешаю всё», если клиенту нужен один method и один заголовок.
Сначала воспроизведите preflight без реальной cookie. Команда проверяет только CORS-контракт; она не доказывает, что actual request будет принят.
\ncurl -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.
Затем отправьте actual request в тестовой среде с тестовыми значениями. Не подставляйте production cookie или token в терминал, историю shell и статью.
\ncurl -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\"}'\ncurl не моделирует браузерную CORS-блокировку: он покажет ответ независимо от CORS headers. Это полезно для проверки API и side effect, но финальный вывод о поведении frontend делайте по browser test или HAR. Если curl с плохим origin всё равно изменяет запись, это не «ошибка CORS», а отсутствие серверной проверки, которую нельзя компенсировать заголовком ответа.
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.
\nconst 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.
| Наблюдение | Что оно подтверждает | Чего оно не подтверждает | Следующая проверка |
|---|---|---|---|
| OPTIONS вернул 204 | Preflight-контракт принят браузером для этой формы | Cookie, token и право на объект | Отправить actual request и проверить серверный отказ/успех |
| В консоли CORS error | JavaScript не получил доступ к 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 получил 200 | API ответил этому HTTP-клиенту | Что браузер отдаст response JavaScript | Повторить в браузере с тем же origin и credentials |
Vary: Origin на ответах, проходящих через cache.Cookie delivery зависит не только от CORS. На неё влияют credentials mode, SameSite, Secure, Domain, Path, third-party cookie restrictions, redirects и политика конкретного браузера. Поэтому один Access-Control-Allow-Credentials не доказывает, что cookie ушла. И наоборот, отправленная cookie не означает, что JavaScript прочитает response.
SameSite — полезный слой снижения риска, но не универсальная замена CSRF token. OWASP допускает узкие случаи, где его достаточно, только при одновременном выполнении нескольких условий: приложение не делит registrable domain с недоверенными хостами, GET не меняет состояние, cookie настроена строго и есть дополнительная проверка origin/referrer. В остальных архитектурах относитесь к SameSite как к defense in depth.
Origin/Referer могут отсутствовать или иметь значение null в отдельных privacy-контекстах, поэтому policy должна явно описывать такие запросы. За прокси target origin нужно брать из доверенной конфигурации или корректно обработанных forwarded headers, а не слепо сравнивать с внутренним host. XSS на доверенном origin, утёкшая сессия, вредоносный service-to-service клиент, authorization flaw, rate limit и CSP лежат за пределами CSRF/CORS и требуют собственных controls.
Разбор можно считать завершённым, когда для endpoint названы разрешённые origins и credentials mode, воспроизводится preflight или объяснено его отсутствие, actual request проверяет CSRF proof до side effect, а authorization отделена от этой проверки. Есть тесты для правильного и неправильного origin, отсутствующего и чужого token, запрещённых method/header и cache-варианта. Если команда видит только заголовок CORS или только зелёный unit test token, контракт ещё не доказан целиком.
\nVary: Origin.Origin.Виджет на https://app.example.test вызывает API на https://api.example.test. После релиза в DevTools появляется CORS error. Пользователь не видит профиль, а команда предлагает поставить Access-Control-Allow-Origin: *. Если API использует cookie, это не исправление. Браузер всё равно скроет credentialed response, а попытка отключить CSRF-проверку может открыть изменение данных с чужой страницы.
Цена ошибки двойная. Рабочий интерфейс перестаёт получать данные. Одновременно сервер может начать принимать state-changing запрос только по cookie. Тогда злоумышленник не обязан читать ответ: ему достаточно заставить браузер жертвы отправить перевод, сменить адрес или удалить запись.
\nТезис: CORS и CSRF отвечают на разные вопросы. CORS определяет, получит ли JavaScript cross-origin доступ к response. CSRF-защита проверяет на сервере, действительно ли запрос на изменение состояния пришёл из разрешённого сценария. Исправляйте эти контуры раздельно.
\nСначала браузер определяет origin. Это комбинация scheme, host и port. Для https://app.example.test и https://api.example.test host различается, поэтому запрос cross-origin. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin.
Клиент может запросить credentials: например, передать cookie через fetch(url, { credentials: 'include' }). Это только намерение клиента. Сервер должен вернуть точный Access-Control-Allow-Origin для разрешённого origin и Access-Control-Allow-Credentials: true, если браузер должен открыть response JavaScript-коду. Wildcard * не совместим с credentialed CORS.
CORS не является authorization. Успешная проверка CORS не означает, что пользователь вошёл, имеет право менять конкретный ресурс или передал CSRF-доказательство. Сервер должен выполнить аутентификацию и authorization независимо от CORS.
\nCSRF появляется потому, что браузер может приложить cookie к cross-site запросу. Простая HTML-форма способна отправить POST с application/x-www-form-urlencoded без доступа к ответу и без preflight. Если endpoint меняет состояние только по cookie, такой запрос опасен.
Для stateful API сервер обычно хранит CSRF-token в сессии и требует его в form field или custom header. Обработчик сравнивает token до mutation. Отсутствующий или неверный token даёт отказ. Origin или Referer check может добавить защиту, но не заменяет token, authorization и проверку бизнес-прав.
\nНиже учебный пример политики. Он не открывает сеть, не создаёт cookie, не запускает браузер и не доказывает поведение конкретного production API. В нём показаны две независимые проверки: CORS для чтения ответа и CSRF для изменения состояния.
\nconst 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-контракта.
Preflight полезен для диагностики формы запроса. Custom header вроде X-CSRF-Token или нестандартный content type часто вызывает OPTIONS-проверку. Но preflight не подтверждает token, сессию и право пользователя. Он проверяет, разрешает ли CORS-политика указанный origin, method и header. Поэтому нельзя считать preflight самостоятельной CSRF-защитой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| В консоли CORS error при cookie-запросе | Wildcard или отсутствует точный origin | Сверить Origin, Access-Control-Allow-Origin, credentials и Vary: Origin | Оставить allow-list точных origin; не подставлять любое входное значение |
| OPTIONS проходит, POST меняет данные без token | Preflight ошибочно приняли за CSRF-защиту | Отправить form-shaped POST без custom header и проверить серверный ответ | Проверять CSRF-token до mutation для всех state-changing методов |
| 403 после добавления token | Token не связан с текущей сессией или не доходит через proxy | Проверить источник token, cookie, заголовок, нормализацию и точку отказа | Исправить передачу и проверку; не отключать middleware целиком |
| Данные не читаются, но запись всё равно меняется | CORS скрывает response, но сервер принимает запрос по cookie | Смотреть server log и статус actual request отдельно от сообщения браузера | Добавить серверный CSRF-контроль и authorization |
Эта схема предполагает cookie-based authentication и браузерный клиент. Она не описывает OAuth bearer token в заголовке, webhook, native app или server-to-server вызов. Для таких клиентов модель угроз и способ доказать полномочия будут другими.
\nSameSite помогает ограничить отправку cookie, но не отменяет серверную проверку. Его результат зависит от атрибутов cookie, браузера, контекста навигации и окружения. Origin может отсутствовать или иметь значение null. Proxy может изменить набор видимых заголовков. Эти случаи нужно включить в отдельную политику, а не молча считать безопасными.
XSS в доверенном origin может обойти многие CSRF-меры, потому что вредоносный код действует внутри разрешённого контекста. Поэтому исправление CSRF не заменяет защиту от XSS, управление cookie и контроль прав.
\nСценарий готов, если команда может предъявить для одного state-changing endpoint четыре независимых доказательства: точный allow-list origin, ожидаемый CORS response, проверку CSRF-token до mutation и отказ при чужом origin или неверном token. В реальном browser test легитимный запрос читает response и меняет только разрешённый ресурс. Отрицательные запросы получают 403 или эквивалентный отказ, а состояние не меняется. Ни один из этих результатов нельзя заменять одним сообщением CORS в консоли.
\nСимптом выглядит знакомо: виджет на https://app.example.test вызывает API на https://api.example.test, а браузер показывает CORS error. Профиль не загружается, и команда предлагает добавить Access-Control-Allow-Origin: * или отключить CSRF-проверку. Цена такой правки — не только сломанный интерфейс. Если API принимает cookie автоматически, злоумышленник может добиться изменения данных, даже не умея прочитать ответ.
Разберём один cookie-аутентифицированный endpoint PATCH /profile. Для него нужно ответить на три разных вопроса: какой origin отправил запрос, может ли JavaScript этого origin прочитать response и доказал ли запрос право изменить состояние. CORS отвечает только на второй вопрос. CSRF-защита отвечает на третий. Аутентификация и authorization остаются отдельными серверными проверками.
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') опасна: строка может пропустить неподконтрольный поддомен или вовсе другой домен.
Запишите для одной операции полный source origin, URL API, method, content type, режим credentials и имена заголовков. Сообщение в консоли не показывает всего пути. Нужно отличить отказ preflight, отправленный actual request с недоступным для JavaScript ответом и серверный отказ до mutation.
\nCORS, Cross-Origin Resource Sharing, — протокол поверх HTTP, которым сервер сообщает браузеру, можно ли передать cross-origin response коду страницы. Если клиент вызывает fetch(url, { credentials: 'include' }), credentials mode влияет на CORS-контракт: сервер должен вернуть точный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * нельзя использовать как разрешённый origin для credentialed response.
Когда разрешённый список вычисляется динамически, response должен различаться по заголовку Origin; для промежуточного кеша это означает Vary: Origin. Нельзя без проверки копировать входной Origin в Access-Control-Allow-Origin. Сначала сравните его с фиксированным allow-list, затем сформируйте заголовок. Allow-list должен описывать конкретную причину доступа, а не все поддомены компании.
CORS не делает пользователя авторизованным и не защищает данные от запроса, который сервер всё равно выполнит. Браузер может скрыть response от JavaScript, но запрос уже мог дойти до API. Поэтому проверку CORS-ответа нельзя ставить вместо authentication, authorization или CSRF-контроля.
\nДля запроса с нестандартной формой браузер часто сначала отправляет OPTIONS. Например, PATCH, JSON-тело и заголовок X-CSRF-Token приводят к preflight. В нём браузер сообщает origin, будущий method и имена заголовков:
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 не было, значит запрос безопасен».
Важна и обратная сторона: custom header создаёт удобную границу для API-клиента, потому что чужая страница не может произвольно добавить его к cross-origin запросу без прохождения CORS. Но это часть общей схемы, а не единственная причина доверять запросу. Заголовок нужно проверить на сервере, связать с сессией и выполнить проверку до изменения данных.
\nНиже — псевдо-JavaScript для сервера. trustedOrigins и имя заголовка — проектные значения. constantTimeEqual должна быть безопасной реализацией сравнения из выбранного фреймворка или криптографической библиотеки; функция в примере не реализована намеренно. Порядок важнее названий: сначала границы запроса, затем CSRF-доказательство, затем право пользователя, и только после этого побочный эффект.
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 без привязки к сессии имеет отдельные риски.
\ncurl не применяет browser policy и потому не может сам показать, что JavaScript увидит response. Он полезен для проверки фактических HTTP-заголовков и серверного статуса. Запускайте команды против тестового API, подставляя только тестовые значения SESSION_COOKIE и CSRF_TOKEN. В production-сессию их не копируйте.
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 от другой тестовой сессии. Для каждого заранее запишите ожидаемый статус и признак отсутствия изменения.
| Наблюдение | Вероятная граница | Что собрать | Следующее действие |
|---|---|---|---|
| OPTIONS отклонён | CORS не разрешил method или header | Origin, 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 error | CORS response оформлен неправильно после actual request | Server status, response headers и факт изменения | Исправить response contract, сохранив CSRF и authorization |
| Всё работает без custom header | Возможно, endpoint принимает простой form-shaped запрос по cookie | POST/PUT-путь без preflight и без CSRF proof в тестовой сессии | Закрыть каждый state-changing путь серверной проверкой |
| Разрешён чужой поддомен | Слабое сравнение host или динамическое эхо origin | Полные origin со scheme и port, конфигурацию allow-list | Сравнивать нормализованный origin с фиксированными значениями |
Vary: Origin и поведение кеша.curl. Сопоставьте DevTools Network с серверным log.Схема относится к браузерному клиенту, который использует cookie или другую автоматически прикладываемую credential. Для bearer token, который JavaScript явно кладёт в заголовок и не получает из cookie, классическая CSRF-модель обычно отличается; это не отменяет проверки authentication, authorization и защиты от XSS. Webhook и server-to-server вызовам нужен свой контракт подписи, replay-защиты и прав.
\nSameSite ограничивает отправку cookie, но его итог зависит от атрибутов cookie, контекста навигации, браузера и политики third-party cookies. Его разумно рассматривать как слой защиты, а не как повод удалить серверную проверку. Origin иногда отсутствует или имеет значение null; proxy может изменить наблюдаемую картину; redirect может привести к другому origin. Для этих случаев нужна явная политика отказа или отдельная проверка, а не молчаливое разрешение.
GET не должен менять состояние. Если legacy endpoint нарушает это правило, его нельзя считать безопасным только из-за метода: OWASP рекомендует защитить такой ресурс от CSRF и планировать миграцию. XSS на доверенном origin также выходит за рамки CORS и может действовать изнутри приложения. Поэтому нужны отдельные меры для XSS, cookie policy, CSP, аудита и ограничения прав.
\nУчебный код не моделирует конкретный framework, браузерный кеш, все правила cookies или сетевые proxy. Его результат — проверяемый порядок условий, а не доказательство безопасности production API. Доказательством служит повторяемый browser/integration тест вместе с server-side evidence на контролируемой тестовой сессии.
\nОдин endpoint можно считать проверенным, если для него названы разрешённые origin и credentials-контракт, воспроизведён preflight либо объяснено его отсутствие, а server log показывает проверку CSRF до побочного эффекта и authorization после неё. Тестовая сессия должна успешно прочитать разрешённый response и изменить только свой профиль. Другой origin, пустой token, token другой сессии и попытка изменить чужой объект должны завершаться отказом без изменения состояния. Один заголовок CORS или один успешный ручной запрос этих доказательств не заменяет.
\nOrigin. Документ не является готовой allow-list-конфигурацией.Симптом обычно выглядит безобидно: в одной вкладке нажали 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. Эти события нельзя свести к одной строке или одному флагу.
\nCookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.
\nSession record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.
Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.
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 доставлена безопасно |
У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.
Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.
\nScope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.
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 или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.
\nRotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.
Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.
Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.
\nПоложительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.
\nОтдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после renewal | Handler не проверяет 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 видна другая cookie | Issue и clear расходятся по Path или Domain | Сравнить атрибуты Set-Cookie без значения | Повторить name, Path и Domain при наличии |
| Сбой только в одном браузере | Отличается cookie policy или порядок ответов | Повторить разрешённый сценарий на конкретной версии и собрать метаданные | Не менять server contract до подтверждения различия |
Минимальный fixture создаёт fixture-s-1, выполняет rotation и получает fixture-s-2. Повторная rotation со старым ID возвращает reject. Stale logout со старым ID также возвращает reject и оставляет successor active. Logout с текущим ID переводит successor в revoked и удаляет current pointer.
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-системы.
Cookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.
Эта модель не выбирает 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__Host-; это work in progress, а не финальный RFC.Симптом знакомый: после 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.
\nCookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.
\nSession record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.
Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.
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 доставлена безопасно |
У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.
Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.
\nScope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.
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 или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.
\nRotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.
Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.
Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.
\nПоложительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.
\nОтдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после renewal | Handler не проверяет 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 видна другая cookie | Issue и clear расходятся по Path или Domain | Сравнить атрибуты Set-Cookie без значения | Повторить name, Path и Domain при наличии |
| Сбой только в одном браузере | Отличается cookie policy или порядок ответов | Повторить разрешённый сценарий на конкретной версии и собрать метаданные | Не менять server contract до подтверждения различия |
Ниже — маленькая модель без зависимостей. Она проверяет инвариант: после rotation stale logout не отзывает successor, а logout current отзывает его. Команду можно запустить в Bash или Zsh на Node.js 18 и новее.
\nnode --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 сможет изменить новую сессию.
Fixture намеренно не является HTTP-тестом. Он не генерирует криптографически случайный ID, не проверяет TLS, реальный Set-Cookie, браузерный cookie jar, CSRF, reauthentication, несколько устройств, задержку базы, распределённую блокировку и порядок сетевых ответов. Для настоящего сервиса этот тест нужно дополнить интеграционными запросами и конкурентным тестом хранилища.
\nCookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.
Эта модель не выбирает 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__Host-. Это рабочий draft, а не утверждение, что он заменяет RFC 6265.Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает новый доступ, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии поздней операцией со старым ID и расследование, в котором нельзя назвать источник истины.
\nТезис статьи прост: cookie доставляет непрозрачный ID, а серверная запись принимает решение. Сервер хранит статус записи, связь между версиями сессии и current ID. Rotation заменяет current ID и делает старый ID недействительным. Logout отзывает запись и очищает cookie с тем же scope. Ни один флаг cookie не заменяет эти проверки.
\nАутентификация отвечает на вопрос «кто прошёл вход?». В этой модели она не представлена. Сервис может получать identity из другого механизма, но затем всё равно проверяет сессию.
\nCookie delivery отвечает на другой вопрос: какой ID браузер приложил к запросу. Имя, домен, путь, Secure и SameSite влияют на доставку. Наличие cookie не доказывает, что запись существует, не отозвана и относится к текущей версии.
\nServer 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 |
Secure ограничивает отправку по защищённому каналу. Он не шифрует запись сессии и не отзывает её. HttpOnly скрывает значение от обычного JavaScript API. Он уменьшает риск кражи через клиентский код, но не устраняет XSS и не запрещает браузеру приложить cookie.
SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты, а не замена CSRF-проверке mutation endpoint. Сценарии с embed, federation и несколькими доменами требуют отдельного решения.
Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Сервер не должен принимать ID только потому, что cookie пришла, и не должен считать удаление cookie доказательством logout. Browser lifetime и server expiry — разные часы.
Префикс __Host- подходит для cookie одного host: нужны Secure, Path=/ и отсутствие Domain. Это не граница авторизации. Административный маршрут всё равно проверяет права на сервере. Для нескольких поддоменов нужен другой scope и явное описание его владельца.
Rotation меняет идентификатор, который сервер считает текущим. Операция переводит старую запись в rotated, создаёт successor и передвигает указатель current. Старый ID не получает новый TTL. Он возвращает отказ вроде stale-session.
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. Нужен атомарный переход от ожидаемого поколения.
\nLogout-current отзывает одну текущую запись. Logout-lineage отзывает цепочку одного входа. Logout-all отзывает все записи identity. Это разные операции. Endpoint должен назвать scope. Иначе поздний logout старой вкладки выключит новую сессию или оставит действующий successor.
\nПосле отзыва сервер очищает cookie с тем же именем, доменом и путём. Это улучшает UX и уменьшает повторные запросы. Источником истины остаётся status серверной записи.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Cookie есть, но ответ 401 | Запись revoked, rotated или expired | Сопоставить ID, status и current | Вернуть единый reject и очистить cookie |
| Старый запрос изменил данные | Проверили наличие ID, но не status/current | Повторить запрос после rotation | Проверять запись до эффекта |
| Старая вкладка выключила новую | Logout отзывает всю lineage | Сопоставить ID и scope в логе | Явно выбрать logout-current или logout-all |
| Стали действующими два ID | Rotation не линеаризована | Отправить два запроса одного generation | Добавить транзакцию или conditional update |
| Прошла cross-site mutation | SameSite принят за полную CSRF-защиту | Проверить Origin и CSRF-контракт | Добавить отдельную серверную проверку |
Пример не генерирует секреты, не читает cookie jar и не отправляет HTTP. Имена fixture-s-1, generation и фиксированный TTL учебные. Их нельзя копировать как production ID. Модель не покрывает распределённые блокировки, clock skew, несколько устройств, CORS, CSRF policy, reauthentication и reverse proxy.
Документы IETF и NIST описывают протокол и термины, но не доказывают корректность конкретной платформы. Browser test не доказывает атомарность базы. Storage test не доказывает scope Set-Cookie. Эти границы проверяют отдельно.
\nМеханизм готов к интеграционной проверке, если для одного handler видны четыре результата: current ID проходит до authorization; rotated ID получает reject без изменения successor; выбранный logout отзывает ровно ожидаемые записи; сервер принимает решение независимо от наличия cookie. В отчёте есть correlation ID, lineage, generation и причина отказа, но нет самого секрета.
\nЕсли один результат нельзя показать отдельно, контракт ещё не определён. Сначала фиксируют владельца перехода и состояние, затем выбирают хранилище и браузерный сценарий.
\nПользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает доступ: обработчик проверяет только наличие cookie и сразу вызывает изменение данных. Цена ошибки измеряется не внешним видом страницы, а записью, которая прошла после отзыва права, или потерей новой сессии из-за позднего ответа старой вкладки.
\nРазберём один вопрос: какие проверки должны пройти до защищённого действия. Cookie — это транспорт непрозрачного идентификатора. Серверная запись — источник состояния. Она хранит статус, срок действия и принадлежность к текущей цепочке. В этой статье выбрана строгая политика: после rotation старый ID сразу отвергается, а logout-current отзывает только предъявленную текущую запись. Другие политики допустимы, но их границы надо назвать в контракте.
\nHTTP-сервер отправляет cookie через Set-Cookie, а браузер возвращает подходящие значения в заголовке Cookie. Так описывает обмен RFC 6265. Сервер может использовать значение как ключ к состоянию, но сам факт доставки не подтверждает, что ключ существует, не отозван и относится к последнему входу.
| Слой | Вопрос | Минимальная проверка | Чего она не доказывает |
|---|---|---|---|
| Cookie delivery | Какой ID прислал user agent? | Имя, scope и значение заголовка | Что запись активна |
| Server record | Можно ли принять этот ID сейчас? | status, expiry и связь с current | Право на конкретный ресурс |
| Lineage | Какой ID заменил предыдущий? | generation или successor | Что переход выполнен атомарно |
| Authorization | Можно ли выполнить действие? | identity, ресурс и операция | Что cookie безопасно доставлена |
Если cookie не пришла, это проблема браузерного scope или транспорта. Если ID пришёл, но запись revoked, это ожидаемый отказ сессии. Если запись active, но роль не разрешает удаление, отказ должен прийти из authorization. Повторная отправка запроса и увеличение TTL не исправляют ни одну из двух последних ошибок.
\nСтатус записи должен описывать решение, которое сервер принимает на входе защищённого handler. Для минимальной модели достаточно четырёх состояний: active, rotated, revoked и expired. У записи также есть lineageId, generation, время истечения и ссылка на текущий ID. Сам ID следует генерировать криптографически случайным и не включать в логи целиком.
| Состояние | Условие | Ответ | Побочный эффект |
|---|---|---|---|
| active и current | Срок сервера не истёк, поколение совпало | Продолжить к authorization | Только разрешённое действие |
| rotated | Есть successor, но ID больше не current | 401 с машинной причиной stale-session | Не выдавать новый successor |
| revoked | Logout или административный отзыв | 401 с причиной revoked-session | Не менять ресурс |
| expired | Истёк серверный срок | 401 с причиной expired-session | Удалить запись по политике хранения |
Причины отказа полезны для метрик, но не должны раскрывать секрет или лишние сведения анонимному клиенту. Внешний ответ может быть единым 401, а точную причину можно оставить в защищённом журнале с correlation ID. Код 403 оставляют для случая, когда субъект установлен, но authorization запретила действие.
Secure просит user agent отправлять cookie только по защищённому соединению, но не отзывает серверную запись и не превращает значение в доказательство подлинности. HttpOnly убирает cookie из обычного JavaScript API; это снижает риск чтения значения клиентским кодом, но не лечит XSS и не останавливает браузер от автоматической отправки cookie.
SameSite=Lax ограничивает часть cross-site запросов и обычно допускает верхнеуровневую навигацию безопасным методом. Это слой defense in depth, а не общий CSRF-контракт: GET, который меняет состояние, клиентский CSRF и неконтролируемые поддомены остаются проблемами. Для mutation endpoint задайте CSRF-токен или проверку Origin/Referer по требованиям приложения.
Пара Max-Age/Expires задаёт срок хранения в user agent. Серверный expiresAt — отдельные часы. Браузер может удалить cookie раньше, а сервер обязан отвергнуть запись после своего срока, даже если cookie всё ещё пришла.
Префикс __Host- ограничивает область cookie: нужны Secure, явный Path=/ и отсутствие Domain. Это привязывает cookie к конкретному host, но не является проверкой роли и не защищает от ошибки в серверном handler. Если продукт работает на нескольких поддоменах или в iframe, это решение может быть неприменимо: scope, SameSite=None, CSRF и доверие к соседним host надо проектировать отдельно.
Set-Cookie: __Host-session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store\nСтрока выше — контракт заголовка, а не готовое значение. Не подставляйте в cookie email, роль или JSON профиля. Приложение само выбирает срок, способ хранения и формат непрозрачного ID.
\nRotation нужна, когда меняется уровень доверия: например, анонимная сессия становится аутентифицированной или меняются привилегии. OWASP рекомендует регенерировать идентификатор после изменения уровня привилегий и считать действующим только current ID. В выбранной модели старая запись получает rotated, создаётся successor с увеличенным поколением, а указатель lineage атомарно переключается на него.
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 без условия оставляют гонку.
Два параллельных запроса могут прочитать один active ID. Если оба безусловно создают successor, браузер получит два ответа Set-Cookie, а последним станет случайный. Тест отправляет два запроса одного поколения и проверяет, что победил один, а проигравший получил stale-session или иной заранее оговорённый безопасный результат.
Logout-current отзывает предъявленную текущую запись. Logout-lineage отзывает все записи одного входа, включая successor. Logout-all отзывает все записи identity на устройствах. Это три разных операции и три разных ожидания пользователя. Старая вкладка не должна выключать новый вход, если endpoint заявляет logout-current.
\n| Операция | Что отзывает | Когда применять | Проверка |
|---|---|---|---|
| current | Текущий ID | Обычная кнопка выхода из этой вкладки | Новое поколение остаётся active |
| lineage | Цепочку одного входа | Подозрение на кражу этого входа | Старый и новый ID получают reject |
| all | Все записи identity | Смена пароля или аварийный отзыв | Другие устройства теряют доступ |
После серверного отзыва ответ может очистить cookie. Чтобы браузер удалил именно созданную cookie, имя, Path и Domain должны совпадать с исходными атрибутами; для __Host- не добавляйте Domain. Очистка улучшает UX, но logout считается выполненным только после смены серверного статуса.
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Ниже приведён контрактный сценарий для тестового окружения. Переменная BASE_URL должна указывать на приложение, где реализованы /session/start, /session/rotate, /protected-resource и /session/logout. Названия учебные; они не являются стандартными маршрутами.
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 намеренно не является рабочим доменом: его заменяет владелец стенда.
| Симптом | Гипотеза | Как проверить | Исправление |
|---|---|---|---|
| Cookie есть, ответ 401 | record 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 отдельным серверным контролем |
Для расследования нужны не значения cookie, а связи между событиями. Логируйте correlation ID, хешированный или усечённый идентификатор, lineage ID, старое и новое поколение, результат перехода и машинную причину отказа. Не пишите в лог полный session ID, заголовок Cookie или содержимое профиля.
Минимальная последовательность событий выглядит так: session.presented → session.lookup → session.transition → authorization.decision → protected.effect. Наличие последнего события при transition=stale — повод искать обход проверки или неправильный порядок вызовов. Лог должен позволять связать два параллельных запроса, но не давать материал для повторного входа.
Строгий reject старого ID подходит для обычной веб-сессии, если параллельные запросы не требуют grace period. Поток с длинной загрузкой, несколькими устройствами, offline-клиентом или несколькими BFF может потребовать иной политики. Тогда задайте срок и число повторных использований явно, привяжите их к операции и всё равно запрещайте старому ID выполнять неожиданные побочные эффекты.
\nЭта статья не задаёт алгоритм CSRF, CORS, reauthentication, распределённых блокировок, clock skew, хранения в конкретной БД или поведение reverse proxy. Browser test не доказывает атомарность базы; unit test хранилища не доказывает, что браузер применил нужные Path, Domain и SameSite. Эти свойства проверяются отдельными тестами на том стеке, который вы выпускаете.
NIST описывает жизненный цикл сессии и повторную аутентификацию, но не выбирает за проект схему таблиц. RFC описывает cookie-протокол, но не знает, какое действие разрешено субъекту. OWASP формулирует прикладные рекомендации, но их надо сопоставить с вашими браузерами, доменами и threat model. Официальная ссылка подтверждает факт или рекомендацию, а не готовность конкретного сервиса.
\nSet-Cookie и очистку в браузере или интеграционном стенде.Origin и без CSRF-доказательства.Cookie/Set-Cookie, область действия и удаление cookie.SameSite и префикс __Host-. Это Internet-Draft, поэтому сверяйте поддерживаемые браузеры и не выдавайте draft за готовый контракт приложения.SameSite и серверные способы защиты mutation endpoint.Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс получает успешный ответ, хотя сессию уже должны были закрыть. В другом варианте старый запрос приходит после rotation и снова проходит, потому что обработчик проверяет только наличие cookie. Ошибка выглядит случайной. Цена ошибки измерима: сервер меняет данные после отзыва доступа, новая сессия может исчезнуть из-за позднего ответа, а расследование не знает, какой ID считался действующим.
\\\nТезис простой: cookie переносит непрозрачный ID, но не принимает решение о доступе. Сервер хранит запись сессии, её статус, срок и связь с текущей версией. Rotation заменяет current ID и делает старый ID непригодным. Logout отзывает серверную запись и отправляет браузеру cookie с тем же scope в прошлом. Эти действия связаны, но не заменяют друг друга.
\\\nАутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.
Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.
\\\nВторой факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.
Третий факт — 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 настроена безопасно |
Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.
SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.
Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».
Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.
Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.
Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.
В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.
\\\nconst 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 должна сделать проверку и переход одной защищённой операцией. Если она только читает запись, а потом отдельно пишет новую, пример не решает гонку.
Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.
Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, host, Path и, если он был, Domain. Дата истечения должна быть в прошлом. Совпадение scope важно: clear cookie с другим Path может оставить исходное значение.
\\\nЭти ветви доказывают разное. Clear cookie улучшает состояние браузера и интерфейс. Только server-side reject доказывает, что отозванный ID больше не принимают. Если logout защищён от CSRF, это отдельная проверка. SameSite не заменяет её во всех потоках.
\\\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после rotation | Handler проверяет наличие cookie, а не status и current | Отправить predecessor после успешного rotation | Отклонять rotated ID до protected effect |
| Logout меняет только интерфейс | Удалили cookie, но не отозвали запись | Повторить запрос с сохранённым ID | Revoke-ить запись и проверить ответ 401/403 |
| Поздний logout закрывает новую сессию | Операция не различает stale и current | Сначала сделать rotation, затем logout старым ID | Выбрать scope logout-current, lineage или all и закрепить его |
| Cookie живёт не там, где её чистят | При issue и clear различаются Path, Domain или host | Сравнить оба Set-Cookie по каждому атрибуту | Сформировать clear из того же scope-контракта |
| Два запроса создают два successor | Rotation разделён между чтением и записью | Проверить concurrent path в разрешённой среде | Добавить транзакционную или conditional линейзацию |
Модель не выбирает 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Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успех, хотя доступ уже должен быть отозван. В другом варианте старый запрос приходит после rotation и проходит, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии из-за позднего ответа и расследование, в котором непонятно, какой ID считался текущим.
\\\nРешение начинается с одного разделения: cookie переносит значение, а сервер принимает решение. Сессия должна иметь запись со статусом, сроком, identity и указателем на текущую версию. Rotation заменяет current ID и делает predecessor непригодным. Logout отзывает серверное состояние и отдельно очищает cookie с тем же scope. Очистка браузера без отказа сервера не является logout.
\\\nАутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.
Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.
\\\nВторой факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.
Третий факт — 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 настроена безопасно |
Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.
SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.
Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».
Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.
Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.
Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.
В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.
\\\nconst 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 должна сделать проверку и переход одной защищённой операцией. Если она только читает запись, а потом отдельно пишет новую, пример не решает гонку.
Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.
Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый ID проходит после rotation | Handler проверяет наличие cookie, а не status и current | Отправить predecessor после успешного rotation | Отклонять rotated ID до protected effect |
| Logout меняет только интерфейс | Удалили cookie, но не отозвали запись | Повторить запрос с сохранённым ID | Revoke-ить запись и проверить ответ 401/403 |
| Поздний logout закрывает новую сессию | Операция не различает stale и current | Сначала сделать rotation, затем logout старым ID | Выбрать scope logout-current, lineage или all и закрепить его |
| Cookie живёт не там, где её чистят | При issue и clear различаются Path, Domain или host | Сравнить оба Set-Cookie по каждому атрибуту | Сформировать clear из того же scope-контракта |
| Два запроса создают два successor | Rotation разделён между чтением и записью | Проверить concurrent path в разрешённой среде | Добавить транзакционную или conditional линейзацию |
Модель не выбирает 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В ревью появляется знакомая фраза: «давайте добавим подпись», «закроем endpoint» или «поставим ограничение». Но никто не может ответить, какие данные защищаются и какой запрос должен быть отклонён. Команда выбирает технологию до того, как описывает угрозу. Ошибка стоит дороже лишней строки кода: контроль может сломать легитимный поток, не закрыть нужное злоупотребление и оставить владельца без доказательства результата.
\nМодель угроз нужна не для красивой схемы. Она связывает четыре вещи: ценный актив, источник запроса, границу доверия и действие, которое не должно пройти. Из этой связи следует контроль и проверка. Если связь не записана, «добавить безопасность» остаётся пожеланием. Если проверка не содержит отрицательного случая, команда не знает, работает ли защита.
\nОпишите не тревогу, а факт. Например: обработчик принимает запрос на изменение заявки, но контракт не говорит, как он отвергает неподтверждённый вход. Это не доказывает уязвимость. Факт только показывает пробел: у изменения нет явного условия отказа и нет артефакта, который его подтверждает.
\nЗатем зафиксируйте цену ошибки. Неподписанный запрос может изменить чужую заявку, если другая проверка не перекрывает этот путь. Слишком общий контроль может, наоборот, отвергать запросы клиентов и создавать обходной ручной процесс. В обоих случаях команда спорит о механизме, пока не назвала объект защиты и допустимое поведение.
\nДля первого прохода достаточно одного потока. Возьмём учебный пример: browser-client отправляет запрос в public-api-to-handler, handler записывает change-request в хранилище. Asset — заявка на изменение. Boundary — место, где публичный вход становится внутренним вызовом обработчика. Abuse — неподтверждённый запрос пытается изменить заявку. Это условная модель. Она не описывает конкретный продукт и не доказывает безопасность production-системы.
\nbrowser-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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Назван control, но нет asset | Выбрали привычный механизм | Что потеряет свойство при злоупотреблении? | Назвать один актив и его свойство |
| Есть threat, но нет boundary | Не указано место защитного решения | Где вход перестаёт быть доверенным? | Поставить границу на DFD и описать переход |
| Есть control, но нет отрицательной ветки | Проверяли только успешный путь | Какой вход обязан получить отказ? | Добавить тест на отказ и ожидаемый результат |
| В evidence написано «проверено» | Не назван метод и артефакт | Что увидит независимый проверяющий? | Указать test, log, trace или ручной шаг |
| Rollback означает «вернуть всё» | Модель смешана с поставкой | Какие файлы, права и данные меняются? | Разделить исходный snapshot и release-план |
Таблица отсекает ложную полноту. Заполненная строка не означает, что риск мал. Она означает, что следующий вопрос имеет адресата и ожидаемый ответ. Если ответ не находится, оставьте поле пустым и остановите выбор контроля. «Неизвестно» полезнее, чем выдуманное «защищено».
\nПроведите границу там, где меняются правила доверия. Для публичного API это может быть вход в handler, но не всегда. Если gateway уже проверяет подпись, а handler получает внутренний вызов, модель должна показать обе границы и владельца каждой проверки. Если вы назвали границей сеть только потому, что она видна на архитектурной схеме, контроль может оказаться не на том участке потока.
\nВ примере ниже signed-request — лишь учебная гипотеза. Её обещание узкое: handler принимает запрос только после проверяемого подтверждения. Это не синоним шифрования транспорта, аутентификации пользователя или авторизации операции. В настоящем API могут потребоваться другой протокол, nonce, защита от повторной отправки, проверка полномочий и журналирование. Статья не выбирает их за владельца системы.
\nМаленькая функция может поймать механические пропуски до обсуждения реализации. Она принимает actor, asset, boundary, abuse и один из заранее названных типов контроля. Для принятой записи возвращает evidence и snapshot для учебного отката. Такой код проверяет только структуру модели. Он не ходит в сеть, не проверяет ключ, не вызывает API и не оценивает риск.
\nconst 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Остановите выбор контроля, если asset неизвестен или принадлежит нескольким владельцам. Нельзя оценить ущерб, пока не ясно, какое свойство защищается. Остановите работу, если boundary спорна и команда не может показать, где именно принимается решение. В этом случае уточните поток и ответственность, а не добавляйте второй механизм наугад.
\nОстановите объявление готовности, если есть только успешный тест. Контроль, который пропускает хороший запрос, ещё не показывает, что плохой запрос получает отказ. Нужны отрицательный вход, ожидаемый код или состояние, а также подтверждение, что запись не изменилась.
\nНе называйте локальную функцию security review, pentest или compliance evidence. Она не видит production, реальные роли, конфигурацию, ротацию ключей, повторную отправку, лимиты и операционные журналы. Учебный пример помогает проверить форму рассуждения. Он не заменяет анализ системы и согласование риска.
\nОдин поток готов к следующему этапу, когда независимый проверяющий по записи может назвать asset, actor, boundary и abuse; найти контроль в конкретном месте; запустить отрицательную проверку; увидеть ожидаемый отказ до изменения asset; определить сохранённый snapshot и условия отката. Если хотя бы один пункт требует устного пояснения автора, модель ещё не завершена.
\nКритерий не означает «угроз больше нет». Он означает, что команда понимает выбранный риск, границу утверждения и следующий реальный тест. Результат может быть «контроль не выбран», «проверка не выполнена» или «нужен владелец». Это корректные исходы. Они лучше фиктивного PASS, который не связан с поведением приложения.
\nВ ревью появляется знакомая фраза: «давайте добавим подпись», «закроем endpoint» или «поставим ограничение». Но никто не может ответить, какие данные защищаются и какой запрос должен быть отклонён. Команда выбирает технологию до того, как описывает угрозу. Ошибка стоит дороже лишней строки кода: контроль может сломать легитимный поток, не закрыть нужное злоупотребление и оставить владельца без доказательства результата.
\nРазберём один поток, а не всю систему. На выходе должна получиться не красивая диаграмма, а связка: актив, актор, граница доверия, злоупотребление, контроль, проверка и откат. Пример ниже учебный: в нём нет реального сервиса, ключа или пользовательских данных. Поэтому каждый вывод будет иметь границу применимости.
\nНачните с наблюдаемого факта. Например, обработчик принимает запрос на изменение заявки, но контракт не говорит, какое условие должно остановить неподтверждённый вход до записи. Это ещё не доказательство уязвимости: другой слой может выполнить проверку раньше. Но это достаточная причина найти владельца решения и предъявить отрицательный тест.
\nЗапишите цену ошибки двумя предложениями. Если проверка действительно отсутствует, внешний клиент может попытаться изменить чужую заявку. Если поставить слишком общий запрет, легитимные изменения начнут получать отказы, а команда создаст ручной обход. В обоих случаях спор о подписи преждевременен: сначала надо назвать объект защиты и допустимое поведение.
\nДля учебного потока зададим следующие значения: browser-client отправляет запрос в public-api, обработчик проверяет вход и пишет change-request в хранилище. Актив — не абстрактные «данные», а состояние конкретной заявки и её целостность. Актор — внешний клиент, который может сформировать запрос. Злоупотребление — попытка изменить заявку без подтверждения и без права на эту операцию.
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Слово «подпись» здесь не является готовым решением. Целостность сообщения, аутентификация отправителя и право изменить заявку — разные свойства. Подписанный запрос не становится автоматически разрешённым для любого пользователя; шифрование канала тоже не заменяет авторизацию. Это различие определяет, какие поля и тесты потребуются в реальном проекте.
\nOWASP предлагает начинать с четырёх вопросов: что мы строим, что может пойти не так, что будем с этим делать и достаточно ли хорошо проверили результат. Для одного API-потока их удобно превратить в поля записи. Поля не доказывают безопасность, зато делают пропуск видимым и дают команде общий словарь.
\n| Поле | Вопрос | Пример | Что проверять |
|---|---|---|---|
| Asset | Что нельзя потерять? | Целостность заявки 42 | Какое состояние меняется и кто им владеет |
| Actor | Кто формирует вход? | Внешний клиент | Какие у него права и какие данные ему доступны |
| Boundary | Где принимается решение? | Вход из public-api в handler | На каком слое проверка обязательна и кто её владелец |
| Abuse | Какое действие нужно остановить? | Запись чужого изменения | Какие вход и состояние воспроизводят сценарий |
| Evidence | Что покажет результат? | 403 до записи, состояние не изменилось | Точный тест, код ответа, событие и состояние после запроса |
Таблица не требует выбрать STRIDE, PASTA или другой метод. OWASP прямо указывает, что его проект не задаёт единственную методику: подход выбирают по контексту, целям приватности и безопасности, зрелости команды и ограничениям поставки. Поэтому в статье фиксируется малый контракт потока, а не объявляется универсальный стандарт.
\nГраница доверия — не обязательно сетевой экран. Это место, где меняются предположения о входе или появляется новое право на действие. В нашем потоке public-api получает внешние данные, а handler решает, можно ли менять актив. Если gateway уже проверяет токен, это нужно записать отдельно: gateway подтверждает одно свойство, handler может отвечать за другое.
\nНарисуйте границу вместе с владельцем. Для каждого контроля ответьте: какой слой его выполняет, какие данные получает и что происходит при отказе. Если два слоя «проверяют авторизацию», но используют разные идентификаторы пользователя, это не избыточная безопасность, а возможное расхождение контракта. Если ни один слой не отвечает за право изменить заявку, подпись запроса проблему не закрывает.
\nНа схеме есть и обратная стрелка. Она относится только к учебному snapshot. В настоящем сервисе откат может затрагивать миграции, права, ключи, очереди и уже записанные изменения. Для них нужен отдельный план совместимости и восстановления, а не обещание «вернуть всё назад».
\n«Используем подпись» описывает механизм, но не критерий. Проверяемая формулировка звучит так: «для запроса на изменение заявки handler проверяет подлинность отправителя и его право на заявку до записи; при нарушении условия возвращает отказ, а актив остаётся неизменным». В этом предложении есть действие, точка решения, отрицательная ветка и наблюдаемый результат.
\nКакая именно технология реализует проверку, зависит от архитектуры. Для браузерного запроса могут понадобиться управление сессией, защита от CSRF (межсайтовой подделки запроса) и авторизация операции. Для webhook от сервиса-партнёра — проверка подписи тела, ограничения времени и защита от повторной доставки. Для внутреннего вызова — отдельная идентичность сервиса и политика доступа. Нельзя выбрать один из этих вариантов только по слову «API».
\nNIST SSDF задаёт высокоуровневые практики безопасной разработки, которые встраиваются в жизненный цикл, но сама публикация не сертифицирует endpoint и не заменяет проверку конкретного кода. Это полезная граница утверждения: модель угроз помогает получить требование и тест, а не выдаёт автоматический знак безопасности.
\nДо подключения сети можно проверить полноту самой записи. Следующий запуск создаёт объект в памяти, отбрасывает пустой актив, неизвестный контроль и возвращает evidence только для принятой модели. Он воспроизводим на Node.js 18 и новее, не обращается к сети и не проверяет криптографическую подпись. Скопируйте блок в терминал целиком:
\nnode --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 проверяет подпись, что идентификатор пользователя нельзя подменить или что хранилище не изменится при реальном запросе.
После фикстуры переходите к приложению. Подготовьте тестовую запись, для которой можно безопасно сравнить состояние до и после. Укажите в тесте идентификатор субъекта, заявки и права; не берите production-секреты и реальные персональные данные. Сначала отправьте допустимый запрос, затем тот же запрос с удалённым подтверждением или чужим идентификатором. Нужны не только ответ и лог, но и проверка, что запись не изменилась.
\nBASE_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; нельзя считать любой отказ доказательством, пока состояние и аудит операции не проверены.
У теста должны быть явные precondition, действие и postcondition. До запроса: заявка принадлежит пользователю A, версия равна 7, actor не имеет права на заявку B. Действие: actor отправляет запрос изменения B. После: сервис возвращает отказ, версия и поля B не меняются, событие отказа содержит безопасный идентификатор операции. Такой набор проверяет поведение, а не наличие строчки с названием middleware.
\nЛог тоже не равен доказательству. Запись «authorization failed» полезна, если по ней можно найти запрос, слой и причину, но без раскрытия токена или персональных данных. Трассировка и метрики помогают увидеть путь, однако их отсутствие в учебной фикстуре ожидаемо. Называйте конкретный артефакт: тест, ответ, снимок состояния, событие или ручной шаг.
\nЗаранее зафиксируйте границы: тестовый стенд не показывает поведение всех прокси; один отрицательный actor не покрывает матрицу ролей; отсутствие записи в локальном журнале не доказывает отсутствие записи в очереди; проверка подписи не подтверждает защиту от повторной отправки. Эти ограничения превращают «PASS» в честный результат с понятным следующим тестом.
\nНе выбирайте механизм, если неизвестен актив или его владелец. Без этого нельзя оценить ущерб и понять, что именно должно остаться неизменным. Остановитесь, если граница спорна: сначала уточните поток и ответственность, иначе два слоя могут проверять разные свойства или не проверять ни одно.
\nНе объявляйте контроль готовым по одному успешному запросу. Он показывает доступный путь, но не показывает отказ злоупотребления. Не называйте учебную функцию security review, pentest, аттестацией или compliance evidence: она не видит реальные роли, конфигурацию, ротацию ключей, повторную доставку, лимиты, очереди и операционные журналы.
\nЕсли домен требует отдельной модели приватности, доступности или регуляторных обязательств, расширьте набор активов и участников. Если меняется архитектура, формат данных или внешняя интеграция, пересмотрите границы и угрозы. Малый поток — хороший первый срез, но не лицензия игнорировать соседние пути.
\nОдин поток можно передать на следующий этап, когда независимый проверяющий по записи называет актив, актора, границу и злоупотребление; находит контроль в конкретном месте; запускает отрицательный сценарий; видит ожидаемый отказ до изменения актива; находит артефакт проверки и понимает условия отката. Если хотя бы один пункт требует устного пояснения автора, запись ещё не завершена.
\nКритерий не означает, что угроз больше нет. Он означает, что команда понимает выбранный риск и знает, какое утверждение подтверждено. Корректный исход может быть «контроль не выбран», «проверка не выполнена» или «нужен владелец». Такой результат полезнее фиктивного PASS, не связанного с поведением приложения.
\n