{ "index": 172, "slug": "editorial-2023-03-field-csrf-cors", "title": "CORS, preflight и CSRF: как найти настоящую причину ошибки cookie API", "excerpt": "Интерфейс теряет ответ, OPTIONS получает отказ, а mutation заканчивается 403. Разбираем границы CORS и CSRF, проверяем контракт по фактам и не снимаем защиту ради быстрого зелёного теста.", "contentHtml": "
После переноса frontend на новый host интерфейс перестаёт читать ответ API. В консоли появляется CORS error. Другой запрос получает 403 от CSRF middleware. Иногда сервер уже выполнил mutation, а браузер только скрыл ответ. Ошибка стоит дорого: команда может открыть API для любого origin, отключить проверку токена или повторить действие пользователя, не понимая, был ли запрос принят.
\nТезис простой: CORS и CSRF проверяют разные свойства запроса. CORS ограничивает, какой origin может прочитать cross-origin response из браузера. CSRF защищает изменение состояния от запроса без доказательства намерения пользователя. Один заголовок CORS не заменяет CSRF-токен. CSRF-токен не чинит отсутствующий preflight contract. Сначала нужно восстановить request contract, потом исправлять ровно его нарушенную часть.
\nЗафиксируйте одну операцию. Запишите origin страницы, URL API, method, content type, режим credentials и имена пользовательских заголовков. Добавьте status, видимые response headers и безопасный корреляционный идентификатор. Запись «браузер ругается на CORS» слишком коротка: она не показывает, был ли preflight, дошёл ли actual request до приложения и выполнился ли side effect.
\nНе берите для проверки production cookie. В учебном или тестовом окружении создайте отдельную сессию, выберите тестовую запись и заранее опишите допустимый side effect. Если controlled browser check пока невозможен, так и напишите: контракт проверен статически, сеть и сессия не проверены. Недостающий evidence — это результат диагностики, а не повод объявлять защиту рабочей.
\nCORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.
Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.
CSRF. Cookie может автоматически приложиться к запросу, созданному другим сайтом. Серверу нужно отдельное доказательство, что запрос сформировало разрешённое приложение. В token-based схеме сервер выдаёт непредсказуемый токен, а затем сравнивает его с токеном в сессии или с корректно связанным double-submit значением до mutation. Отсутствующий или неверный токен должен остановить side effect.
\nAPI='https://api.example.test/v1/profile/email'\nORIGIN='https://app.example.test'\n\n# OPTIONS не использует реальные cookies и показывает preflight contract.\ncurl --include --request OPTIONS \"$API\" --header \"Origin: $ORIGIN\" --header \"Access-Control-Request-Method: POST\" --header \"Access-Control-Request-Headers: content-type,x-csrf-token\"\n\n# В ответе ожидаются exact origin и только нужные method/headers:\n# Access-Control-Allow-Origin: https://app.example.test\n# Access-Control-Allow-Credentials: true\n# Access-Control-Allow-Methods: POST\n# Access-Control-Allow-Headers: Content-Type, X-CSRF-Token\n# Vary: Origin\n\n# Отрицательный путь: токен отсутствует. Только тестовая сессия и запись.\ncurl --include --request POST \"$API\" --header \"Origin: $ORIGIN\" --header \"Content-Type: application/json\" --cookie \"session=TEST_SESSION_ONLY\" --data-raw '{\"email\":\"qa@example.test\"}'\n\n# Положительный путь: подставьте token из test setup, не production secret.\ncurl --include --request POST \"$API\" --header \"Origin: $ORIGIN\" --header \"Content-Type: application/json\" --header \"X-CSRF-Token: TEST_TOKEN_ONLY\" --cookie \"session=TEST_SESSION_ONLY\" --data-raw '{\"email\":\"qa@example.test\"}'\nЭти команды показывают HTTP-контракт, но не моделируют browser same-origin policy. Успешный ответ curl не доказывает, что JavaScript прочитает response. В логах дополнительно сопоставьте correlation ID, решение preflight, результат CSRF-проверки и факт mutation. POST запускайте только в изолированном окружении, с тестовой сессией и тестовой записью; реальные cookie и token values нельзя переносить в shell history или журналы.
Если OPTIONS завершился отказом, actual request обычно не начинается. Если же preflight не нужен, браузер может отправить запрос, который другой сайт способен инициировать без чтения ответа. Поэтому отрицательная проверка должна смотреть не только на CORS headers, но и на неизменность тестовой записи после запроса без CSRF-доказательства.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| JavaScript не читает response | Origin отсутствует в allow-list | Сверить полный origin с Access-Control-Allow-Origin | Добавить только нужный origin |
| Credentials response заблокирован | Wildcard или нет Allow-Credentials | Проверить пару заголовков и режим credentials | Вернуть exact origin и явный credentials contract |
| OPTIONS получает отказ | Method или custom header не разрешён | Сопоставить request headers с ответом preflight | Разрешить один нужный method/header |
| POST form получил 403 | CSRF-токен отсутствует или не совпал | Проверить server reason до mutation | Исправить выдачу и передачу токена |
| Mutation прошёл, UI увидел error | Server action и CORS visibility различаются | Сверить application log и browser evidence | Исправить CORS, не снимая CSRF |
| 403 после token check | Нет business permission | Разделить CSRF reason и authorization reason | Исправить policy ресурса |
Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.
\nЕсли frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.
Затем выпишите фактический method и имена заголовков из client code. Не разрешайте «все методы и все headers» ради того, чтобы прекратить ошибку. Широкий ответ ухудшает review и превращает ошибку клиента в незаметно разрешённый путь. Если используется form-shaped POST, проверьте его отдельно: отсутствие preflight не означает отсутствие CSRF-риска.
\nЕсли запрос требует preflight, actual request может не начаться после отказа OPTIONS. Для другого request shape сервер может принять HTTP-запрос, но браузер не даст JavaScript прочитать response. Поэтому нужны оба слоя: browser DevTools или HAR и proxy/application evidence с корреляционным идентификатором. Один слой не заменяет второй.
\nVary: Origin, если ответ зависит от входного origin.Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что origin с другим port не получает credentialed response, неизвестный header не проходит preflight, а POST без token не меняет состояние. Это assertions над моделью контракта. Они не запускают gateway, браузер, framework middleware или настоящую session store.
\nПосле PASS такого теста корректная формулировка звучит так: «проверены заданные правила контракта и отрицательные ветки». Нельзя писать «CORS и CSRF проверены в сети», если не было controlled browser/API evidence. Нельзя переносить в заметку production cookie, token values и идентификаторы реальных пользователей.
\nЭтот маршрут не заменяет XSS review, аудит cookie attributes, CSP, authorization test или penetration test. XSS на доверенном origin меняет картину: чужой скрипт может использовать доступные ему API и токены. CORS и CSRF не защищают от выполнения вредоносного JavaScript внутри собственного origin. Не существует универсального значения TTL токена, набора SameSite или списка trusted origins: решение зависит от framework, browser support, session model и threat model.
Endpoint готов к review, когда видны четыре доказательства: точный разрешённый origin; корректный credentialed response и preflight contract, если они нужны; server-side rejection без CSRF proof до side effect; отдельная проверка business permission. Если есть только CORS header, работа не готова. Если есть только token test, frontend всё ещё может не прочитать response. Если есть только fixture PASS, нет доказательства интеграции. Эти слои дополняют друг друга и не заменяют друг друга.
\n