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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

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

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

\n

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

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

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

\n

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

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

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

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

Ограничения

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n" }