DarkRiDDeR12 мин

Cookie API и виджет: как не перепутать CORS с CSRF

БезопасностьHTTP

Ошибка обычно начинается с рабочего виджета на https://app.example.test и cookie API на https://api.example.test. После релиза браузер показывает CORS error, а в конфигурации появляется соблазн поставить Access-Control-Allow-Origin: * и выключить проверку токена. Цена двойная: credentialed response всё равно останется недоступным по CORS, а серверная граница mutation может ослабнуть. Сначала надо разделить два решения, а не подбирать один заголовок.

В этой статье есть один учебный контракт для обсуждения конфигурации. Он принимает заданные строки origin, method, headers и token, возвращает отдельные поля cors и csrf и проверяется в Node. Контракт намеренно не запускает браузер: он не вызывает fetch, не поднимает два host, не отправляет cookie и не делает OPTIONS. Поэтому PASS полезен как регрессия смысла в коде статьи, но не как воспроизведение сетевого обмена.

Один запрос, три владельца решения

Origin — это не название продукта и не часть пути URL. Для веб-модели это tuple scheme, host и port. Поэтому https://app.example.test и https://api.example.test уже находятся по разные стороны origin, хотя оба host могут принадлежать одной команде. Путь /profile или общий registrable domain этого не меняют. Это первый факт, который надо записать в тикет до разговора о cookie и заголовках.

Дальше владельцы расходятся. Браузер сверяет CORS response и решает, откроет ли JavaScript доступ к ответу cross-origin запроса. Сервер CORS-политикой описывает, какой чужой origin может получить это представление. Отдельно обработчик state-changing endpoint проверяет аутентификацию, права и CSRF-доказательство. Cookie отвечает только на вопрос, какую сессию браузер может попытаться приложить; она не заменяет ни permission, ни доказательство намерения.

Минимальный контракт для cookie API
ВопросКто принимает решениеНаблюдаемый критерийЧего критерий не доказывает
Что считается другим origin?браузерная модель URIscheme + host + port не совпалидоверие между сервисами или право пользователя
Может ли JS прочитать response?браузер после CORS responseAccess-Control-Allow-Origin соответствует originчто mutation разрешён сервером
Можно ли использовать credentials?клиентская декларация и CORS responseexact origin плюс Access-Control-Allow-Credentials: trueчто cookie реально будет приложена во всех браузерах
Нужно ли сначала проверить shape?браузерный CORS алгоритмmethod, content type или custom header не safelistedчто actual request безопасен или уже отправлен
Можно ли изменить данные?серверный обработчикorigin policy и CSRF token прошли проверкучто у пользователя есть нужное бизнес-право

Сначала зафиксируйте контракт, затем правьте proxy

Для credentialed API allow-list должен состоять из точных origin. Если политика отражает пришедший Origin, сначала она должна сравнить нормализованное значение с собственным списком; отражать любую строку нельзя. Пара Access-Control-Allow-Origin: * и credentials не является совместимым договором: историческая рекомендация CORS прямо запрещает wildcard для ресурса, который поддерживает credentials. Значит, такой ответ не лечит легитимный виджет и не должен быть обходом 403.

У request со значением credentials: 'include' есть две границы. На стороне клиентского кода это намерение участвовать в credentialed обмене. На стороне сервера нужны exact origin и Access-Control-Allow-Credentials: true, если ответ должен стать доступным браузерному коду. При этом cookie policy конкретного браузера, атрибуты cookie и пользовательские настройки могут не дать cookie пройти. Не превращайте успешный заголовок CORS в утверждение, что сервер увидел сессию.

Учебный fixture: договор, а не CORS repro

Ниже объект описывает ожидаемую конфигурацию: API доверяет одному origin для CORS и двум origin для CSRF, потому что same-origin форма API тоже должна пройти серверную проверку. Строка fixture-token-2023 не является секретом, токеном реальной сессии или примером генерации. Она нужна только для отрицательной ветки deny-token. В функции нет HTTP-клиента, cookie jar, времени, CORS cache и доступа к файловой системе.

const policy = {
  targetOrigin: 'https://api.example.test',
  corsAllowedOrigins: ['https://app.example.test'],
  allowCredentials: true,
  csrfTrustedOrigins: ['https://api.example.test', 'https://app.example.test'],
  csrfToken: 'fixture-token-2023',
};

const result = evaluateInMemoryCsrfCorsContract(policy, {
  declaredOrigin: 'https://app.example.test',
  method: 'PATCH',
  contentType: 'application/json',
  headerNames: ['content-type', 'x-csrf-token'],
  credentialsMode: 'include',
  authentication: 'session-cookie',
  csrfToken: 'fixture-token-2023',
});

console.log(result.cors.decision); // allow-exact-origin-and-credentials
console.log(result.csrf.decision); // accept-origin-and-token
console.log(result.boundary.network); // not-opened

Результат содержит два независимых ответа. Поле cors.decision говорит о том, совместима ли объявленная CORS-политика с заданным origin и credentials. Поле csrf.decision говорит о том, допустил бы учебный серверный договор mutation при заданных origin и token. Их нельзя склеить в один boolean. Например, same-origin request вообще не нуждается в CORS, но cookie-session POST без CSRF token остаётся deny-token. Это и есть нужная проверка против привычной ошибки «у нас same-origin, значит токен можно убрать».

Код страницы с origin app.example.test обращается к API на api.example.test. Внутри API CORS разрешает чтение response только exact origin с credentials, а CSRF отдельно требует origin и token до mutation.
CORS и CSRF показаны как два независимых решения сервера и браузера. Схема не изображает реальный браузерный запуск, передачу cookie, OPTIONS или лог конкретного API.

CSRF-контроль остаётся на сервере

CORS не запрещает серверу принять любой HTTP request; его основная роль — ограничить, какой browser code получит доступ к response при cross-origin API-вызове. В старой CORS Recommendation это разделение названо прямо: simple cross-origin requests могут иметь user credentials, а ресурс с действием, отличным от получения данных, должен защищаться от CSRF явно переданным непредсказуемым значением. Практический вывод уже не зависит от названия frontend framework: mutation на cookie session требует серверного условия отказа.

Для stateful приложения типичный базовый вариант — synchronizer token: сервер связывает значение с сессией, клиент возвращает его в form field или custom header, обработчик сравнивает значение до побочного эффекта. Origin или Referer check может быть дополнительной защитой, если команда описала нормальное отсутствие этих заголовков и режим отказа. Ни token, ни origin check не заменяют authorization: после CSRF-проверки пользователь всё ещё может не иметь права менять чужой ресурс.

Preflight полезен, но не становится CSRF-защитой

Custom header вроде X-CSRF-Token и JSON PATCH дают request shape, для которого browser CORS алгоритм может потребовать preflight. Это полезный сигнал: чужая страница не может просто добавить произвольный header без CORS-проверки. Но preflight отвечает на другой вопрос — допускает ли ресурс такой method и header для указанного origin. Он не подтверждает сессию, значение token, пользователя или смысл операции.

Обратный пример важнее. Обычный HTML form может отправить cross-origin POST с application/x-www-form-urlencoded без такого этапа. Если API принимает этот content type и меняет состояние только по cookie, защищаться надеждой на preflight нельзя. Поэтому fixture специально проверяет form-shaped POST: он получает статус safelisted-shape, но без token возвращает deny-token. Название статуса принадлежит учебному контракту; он не говорит, что браузер уже сделал или сделает в сети.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. В консоли виден CORS error или endpoint вернул 403. Сохраните origin страницы, URL API, method, content type, имена request headers и server status до изменения конфигурации.
  2. Причина. Сначала сравните scheme, host и port. Разные subdomain или port — это другой origin, даже когда продукт называет их одним сайтом.
  3. Проверка CORS. Для credentialed API сверьте exact allow-list, Access-Control-Allow-Credentials: true и ответ на нужный method/header. Не заменяйте список на wildcard.
  4. Проверка CSRF. На сервере отдельно назовите state-changing methods, источник token, момент сравнения и результат mismatch. Проверка должна стоять до mutation и не зависеть от того, прочитает ли JS response.
  5. Действие. Исправьте только отсутствующее условие: allow-list для доверенного frontend origin, узкий preflight contract или выдачу и передачу token. Не отключайте middleware целиком ради одного route.
  6. Регрессия. Выполните node web/scripts/upgrade-2023-03.mjs --verify-fixture. PASS подтверждает 12 assertions объекта в памяти; для настоящего браузера и API нужен отдельный интеграционный сценарий.

Границы утверждений и следующий шаг

Fixture не проверяет, как конкретный браузер применяет SameSite, third-party cookie policy, redirect, cache или version-specific CORS details. Он не создаёт реальную сессию, не генерирует криптографический token, не сравнивает token constant-time и не проверяет proxy. Входной origin также не является прочитанным HTTP header: это label, заданный test. Если в вашей системе origin может отсутствовать, быть null или проходить через несколько proxy, это отдельное правило сервера и отдельный набор интеграционных проверок.

Следующий безопасный шаг — выбрать один state-changing route с cookie authentication и записать четыре вещи рядом с его тестом: exact client origin, CORS response для этого origin, CSRF proof и ожидаемый 403 при mismatch. Затем отдельно проверить реальный browser flow в разрешённом окружении с его DevTools и server logs. Не называйте результат fixture доказательством этого прогона: разные артефакты отвечают на разные вопросы.

Историческая граница марта 2023

В статье используются RFC 6454 от декабря 2011 года, W3C CORS Recommendation от 16 января 2014 года и commit snapshot WHATWG Fetch Standard от 24 марта 2023 года. Все три источника доступны не позднее марта 2023. Они задают модель и протокол, но не дают готовой конфигурации для доменов, cookie policy или framework middleware конкретного проекта.

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