{ "index": 173, "slug": "editorial-2023-03-mechanism-csrf-cors", "title": "CSRF и CORS: где заканчивается доступ к ответу и начинается защита действия", "excerpt": "Разбираем на одном cookie-аутентифицированном endpoint, чем отличаются origin, CORS, preflight и CSRF-проверка, как воспроизвести запрос через curl и где заканчиваются гарантии каждого слоя.", "contentHtml": "

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

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

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

\n

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

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

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

\n

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

\n

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

\n

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

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

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

\n

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

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

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

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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n" }