{ "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.