Ошибка обычно начинается с рабочего виджета на 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, ни доказательство намерения.
| Вопрос | Кто принимает решение | Наблюдаемый критерий | Чего критерий не доказывает |
|---|---|---|---|
| Что считается другим origin? | браузерная модель URI | scheme + host + port не совпали | доверие между сервисами или право пользователя |
| Может ли JS прочитать response? | браузер после CORS response | Access-Control-Allow-Origin соответствует origin | что mutation разрешён сервером |
| Можно ли использовать credentials? | клиентская декларация и CORS response | exact 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, значит токен можно убрать».
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. Название статуса принадлежит учебному контракту; он не говорит, что браузер уже сделал или сделает в сети.
Маршрут: симптом → причина → проверка → действие
- Симптом. В консоли виден CORS error или endpoint вернул 403. Сохраните origin страницы, URL API, method, content type, имена request headers и server status до изменения конфигурации.
- Причина. Сначала сравните scheme, host и port. Разные subdomain или port — это другой origin, даже когда продукт называет их одним сайтом.
- Проверка CORS. Для credentialed API сверьте exact allow-list,
Access-Control-Allow-Credentials: trueи ответ на нужный method/header. Не заменяйте список на wildcard. - Проверка CSRF. На сервере отдельно назовите state-changing methods, источник token, момент сравнения и результат mismatch. Проверка должна стоять до mutation и не зависеть от того, прочитает ли JS response.
- Действие. Исправьте только отсутствующее условие: allow-list для доверенного frontend origin, узкий preflight contract или выдачу и передачу token. Не отключайте middleware целиком ради одного route.
- Регрессия. Выполните
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 конкретного проекта.
Проверяемые источники
- WHATWG Fetch Standard, commit snapshot 8f109835, 24 марта 2023 — зафиксированная версия стандарта, доступная до конца марта 2023; нужна для терминов CORS, credentials и preflight, а не как описание конкретной реализации браузера.
- RFC 6454: The Web Origin Concept, декабрь 2011 — стандарт IETF: origin определяется tuple scheme, host и port; документ также описывает заголовок Origin.
- W3C Cross-Origin Resource Sharing, Recommendation 16 января 2014 — стабильная историческая рекомендация, доступная задолго до марта 2023; отдельно описывает credentials, preflight и то, что state-changing simple requests требуют CSRF-защиты.