Проблема появляется, когда в одном code review смешивают три фразы: «origin наш», «fetch идёт с credentials» и «preflight нас защищает». Тогда CORS header принимают за permission на изменение данных, а 403 от CSRF middleware — за повод расширить allow-list. Цена — потеря диагностики: по CORS error нельзя понять, дошёл ли запрос, был ли response скрыт от JavaScript или сервер отверг mutation по token.
Ниже — модель для конкретного endpoint. Она не обещает одинаковый network trace в Chrome, Safari и webview. Вместо этого мы разложим термины и проверим их на in-memory contract: request values заданы тестом, а browser, Fetch, cookie и OPTIONS отсутствуют. Реальную сетевую проверку нужно делать отдельным browser/integration test с двумя controllable origin и server evidence.
Origin: короткий tuple вместо слова «сайт»
RFC 6454 описывает origin через scheme, host и port. У https://app.example.test и https://app.example.test:8443 совпадает host, но port различается; это cross-origin пара. У http://app.example.test и HTTPS-версии различается scheme. URL path, query и fragment не входят в tuple. Эта механика кажется простой, но именно здесь ломаются allow-list, когда разработчик сравнивает только suffix домена или считает, что два приложения под одним registrable domain автоматически равноправны.
Origin не является моделью бизнес-доверия. Два сервиса могут быть на одном origin и иметь разные роли, а один доверенный frontend может жить на другом origin. Поэтому origin check полезно трактовать как одно условие в серверной политике, а не как готовый authorization model. В частности, origin https://admin.example.test не получает право вызывать user API только из-за общего слова example.test; он должен быть внесён в отдельный точный contract и пройти review как новый principal.
| Термин | Точное значение в разборе | Проверка | Опасная подмена |
|---|---|---|---|
| Origin | tuple scheme, host, port | сравнить canonical tuple source и target | «это тот же домен» |
| Credentials mode | намерение client API участвовать в credentialed request | посмотреть вызов и CORS response contract | «у сервера точно есть сессия» |
| CORS permission | браузерный доступ origin к response representation | exact Access-Control-Allow-Origin и условия credentials | «endpoint авторизовал mutation» |
| Preflight | CORS gate для заявленных method/header/content type | OPTIONS contract для узкого набора | «сервер уже проверил CSRF» |
| CSRF proof | серверное доказательство допустимого intent для cookie mutation | token/origin policy до side effect | «cookie означает intent» |
Credentials: декларация клиента и ответ сервера
В Fetch credentials mode описывает, как request относится к credentials. Для cross-origin API этого недостаточно: server response должен согласовать доступ к representation. Когда нужен credentialed response, wildcard Access-Control-Allow-Origin: * не подходит; CORS Recommendation требует exact origin и Access-Control-Allow-Credentials: true. Это ограничение полезно читать буквально: заголовки отвечают за видимость response для кода другого origin, а не за безопасную схему аутентификации сами по себе.
Порядок настройки простой. Сначала решите, нужен ли browser client cookie-authenticated API. Если да, назовите доверенные origin и endpoints, которые они читают, затем включайте credentials только на этих response. Явный bearer или capability token с узким scope — другая architecture choice; он требует отдельного анализа хранения token, XSS и rollout.
Preflight: gate формы запроса, не доказательство intent
Preflight появляется не потому, что endpoint опасный, а потому что browser видит cross-origin request shape вне CORS safelist: например, PATCH, JSON content type или X-CSRF-Token. Он проверяет, готов ли ресурс обслужить такой method и такие header names для указанного origin. Успешный OPTIONS позволяет перейти к следующему этапу CORS algorithm, но не сообщает API, что token совпал с сессией или что пользователь может выполнить операцию.
Технический контрпример — обычная form submission. POST с application/x-www-form-urlencoded принадлежит safelisted shape, хотя может менять состояние. Поэтому сервер, который защищает POST только тем, что JSON endpoint обычно preflighted, оставляет другую поверхность. Надёжная формулировка для review: preflight может быть дополнительным барьером для custom-header flow, но CSRF control обязан покрыть state-changing cookie routes независимо от наличия OPTIONS. Отдельно проверьте, что GET и HEAD не делают side effect; иначе даже корректный token flow не спасает дизайн метода.
Проверьте разные решения на одном объекте
Учебный код не реализует Fetch Standard. Его классификатор намеренно уже спецификации: он отмечает method, content type и header names, которые мы задали в fixture, и пишет safelisted-shape или non-safelisted-shape. Он не разбирает все byte-level ограничения заголовков, не поддерживает CORS cache и не должен использоваться как production middleware. Такая узость важна: компактный test остаётся честным, пока не делает вид, что заменил браузер.
const formWithoutToken = evaluateInMemoryCsrfCorsContract(policy, {
declaredOrigin: 'https://app.example.test',
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
headerNames: [],
credentialsMode: 'include',
authentication: 'session-cookie',
csrfToken: '',
});
console.log(formWithoutToken.shape.status); // safelisted-shape
console.log(formWithoutToken.csrf.decision); // deny-token
// Это contract labels, а не отправленный form или browser trace.
В примере form-shaped POST получает safelisted-shape, однако CSRF-ветка всё равно возвращает deny-token. Это не прогноз HTTP traffic. Это проверяемое правило нашего договора: session-cookie mutation нельзя принять без совпадающего заданного token и trusted origin. Рядом fixture проверяет другой случай: port 8443 делает origin отличным, даже если host тот же. Такая отрицательная ветка полезнее happy path, потому что именно она защищает review от скрытого допущения о домене.
Где проходит ответственность сервера
CORS configuration обычно живёт в proxy, API gateway или framework middleware. CSRF rule может жить в application framework, route guard или отдельном endpoint policy. Эти места могут быть разными, но контракт должен быть единым: proxy не разрешает лишний origin ради обхода 403, а приложение не рассчитывает, что proxy остановит form POST. Если у одного endpoint особое исключение, назовите его вход, owner, expiration и test. Глобальное disableCors или disableCsrf превращает локальную проблему в новый неявный baseline.
Права пользователя остаются третьим серверным решением. Допустим, origin и token прошли, но пользователь пытается изменить entity другого account. CSRF не обязан и не может решить это правило: он защищает от поддельного запроса в контексте текущей сессии, а authorization сравнивает actor с объектом и действием. В ответах не смешивайте причины. 403 может означать token mismatch, untrusted origin или missing permission; для клиента можно дать безопасный общий ответ, а в server log оставить корреляционный reason без секретного token.
Маршрут: симптом → причина → проверка → действие
- Симптом. В PR появился
credentials: 'include', custom header или новый frontend host. Выпишите source origin и target origin целиком, включая scheme и port. - Причина. Определите, что не совпало: tuple origin, CORS allow-list, credentials response, preflight method/header либо server-side CSRF proof. Не называйте все причины одним CORS error.
- Проверка tuple. Добавьте отрицательный пример с другим scheme, subdomain или port. Он должен не попасть в trusted allow-list без явного решения команды.
- Проверка CORS. На credentialed route ожидайте exact origin и
Access-Control-Allow-Credentials: true; wildcard означает неверный contract, а не широкую совместимость. - Проверка CSRF. Отдельно отправьте в настоящем test environment invalid token или foreign origin и убедитесь, что mutation не выполняется. Не делайте вывод из OPTIONS response.
- Действие. Уменьшите allow-list до нужного origin, оставьте CSRF middleware включённым и добавьте test на отклонённую ветку. Если меняется authentication architecture, остановите этот PR и оформите отдельное решение.
Ограничения модели и следующий шаг
Статья не утверждает, что каждая cookie автоматически отправляется cross-origin: это зависит от атрибутов cookie, browser policy, режима приватности и контекста. Она также не утверждает, что Origin header всегда доступен в каждом конкретном запросе или что один token format подходит для любого framework. Для synchronizer token нужны выдача, хранение, rotation, comparison и обработка back navigation; для double-submit pattern — отдельная защита от cookie injection. Эти варианты надо сверять с документацией используемого framework и threat model конкретного приложения.
Следующий шаг — провести небольшой review одного endpoint: нарисовать source и target origin, назвать credentialed response contract, перечислить form-shaped и custom-header paths, затем зафиксировать server-side proof до mutation. После этого fixture можно оставить как охрану терминов, а реальный browser test — как evidence интеграции. Если один из двух артефактов отсутствует, не заменяйте его вторым: in-memory PASS не заменяет DevTools и logs, а успешный ручной запрос не документирует правило на следующий refactor.
Историческая граница марта 2023
RFC 6454, W3C CORS Recommendation и WHATWG Fetch snapshot по ссылкам были опубликованы или зафиксированы до конца марта 2023. W3C Recommendation здесь используется как датированный нормативный источник CORS-терминов; Fetch snapshot фиксирует доступную на тот момент редакцию living standard. Никакой из документов не описывает ваши cookie attributes, reverse proxy или route permissions без дополнительной конфигурации и теста.
Проверяемые источники
- 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-защиты.