DarkRiDDeR12 мин

Origin, credentials и preflight: где заканчивается модель браузера

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

Проблема появляется, когда в одном 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.

Три слова, которые нельзя подменять друг другом
ТерминТочное значение в разбореПроверкаОпасная подмена
Origintuple scheme, host, portсравнить canonical tuple source и target«это тот же домен»
Credentials modeнамерение client API участвовать в credentialed requestпосмотреть вызов и CORS response contract«у сервера точно есть сессия»
CORS permissionбраузерный доступ origin к response representationexact Access-Control-Allow-Origin и условия credentials«endpoint авторизовал mutation»
PreflightCORS gate для заявленных method/header/content typeOPTIONS contract для узкого набора«сервер уже проверил CSRF»
CSRF proofсерверное доказательство допустимого intent для cookie mutationtoken/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 не спасает дизайн метода.

Три слоя cross-origin запроса: origin как tuple scheme host port; CORS как условие доступа к response, включая credentials и preflight; CSRF как server-side проверка origin и token перед state-changing request.
Диаграмма задаёт порядок вопросов для ревью. Она не является трассой Fetch, не показывает cookie delivery и не утверждает, что preflight происходит для каждого нарисованного запроса.

Проверьте разные решения на одном объекте

Учебный код не реализует 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.

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

  1. Симптом. В PR появился credentials: 'include', custom header или новый frontend host. Выпишите source origin и target origin целиком, включая scheme и port.
  2. Причина. Определите, что не совпало: tuple origin, CORS allow-list, credentials response, preflight method/header либо server-side CSRF proof. Не называйте все причины одним CORS error.
  3. Проверка tuple. Добавьте отрицательный пример с другим scheme, subdomain или port. Он должен не попасть в trusted allow-list без явного решения команды.
  4. Проверка CORS. На credentialed route ожидайте exact origin и Access-Control-Allow-Credentials: true; wildcard означает неверный contract, а не широкую совместимость.
  5. Проверка CSRF. Отдельно отправьте в настоящем test environment invalid token или foreign origin и убедитесь, что mutation не выполняется. Не делайте вывод из OPTIONS response.
  6. Действие. Уменьшите 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 без дополнительной конфигурации и теста.

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