{ "index": 174, "slug": "editorial-2023-03-practice-csrf-cors", "title": "Cookie API и виджет: как не перепутать CORS с CSRF", "excerpt": "Разберите CORS и CSRF по отдельности: один механизм управляет доступом JavaScript к ответу, другой решает на сервере, можно ли принять изменение данных.", "contentHtml": "

Виджет на https://app.example.test вызывает API на https://api.example.test. После релиза в DevTools появляется CORS error. Пользователь не видит профиль, а команда предлагает поставить Access-Control-Allow-Origin: *. Если API использует cookie, это не исправление. Браузер всё равно скроет credentialed response, а попытка отключить CSRF-проверку может открыть изменение данных с чужой страницы.

\n

Цена ошибки двойная. Рабочий интерфейс перестаёт получать данные. Одновременно сервер может начать принимать state-changing запрос только по cookie. Тогда злоумышленник не обязан читать ответ: ему достаточно заставить браузер жертвы отправить перевод, сменить адрес или удалить запись.

\n

Тезис: CORS и CSRF отвечают на разные вопросы. CORS определяет, получит ли JavaScript cross-origin доступ к response. CSRF-защита проверяет на сервере, действительно ли запрос на изменение состояния пришёл из разрешённого сценария. Исправляйте эти контуры раздельно.

\n

Механизм по шагам

\n

Сначала браузер определяет origin. Это комбинация scheme, host и port. Для https://app.example.test и https://api.example.test host различается, поэтому запрос cross-origin. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin.

\n

Клиент может запросить credentials: например, передать cookie через fetch(url, { credentials: 'include' }). Это только намерение клиента. Сервер должен вернуть точный Access-Control-Allow-Origin для разрешённого origin и Access-Control-Allow-Credentials: true, если браузер должен открыть response JavaScript-коду. Wildcard * не совместим с credentialed CORS.

\n

CORS не является authorization. Успешная проверка CORS не означает, что пользователь вошёл, имеет право менять конкретный ресурс или передал CSRF-доказательство. Сервер должен выполнить аутентификацию и authorization независимо от CORS.

\n

CSRF появляется потому, что браузер может приложить cookie к cross-site запросу. Простая HTML-форма способна отправить POST с application/x-www-form-urlencoded без доступа к ответу и без preflight. Если endpoint меняет состояние только по cookie, такой запрос опасен.

\n

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

\n

Конкретный пример

\n

Ниже учебный пример политики. Он не открывает сеть, не создаёт cookie, не запускает браузер и не доказывает поведение конкретного production API. В нём показаны две независимые проверки: CORS для чтения ответа и CSRF для изменения состояния.

\n
const corsOrigins = new Set(['https://app.example.test']);\nconst csrfOrigins = new Set([\n  'https://app.example.test',\n  'https://api.example.test',\n]);\n\nfunction checkRequest({ origin, credentials, method, csrfToken }) {\n  const cors = corsOrigins.has(origin) &&\n    (!credentials || origin !== '*');\n\n  const safeMethod = new Set(['GET', 'HEAD', 'OPTIONS']).has(method);\n  const csrf = safeMethod ||\n    (csrfOrigins.has(origin) && csrfToken === 'token-from-session');\n\n  return { cors: cors ? 'allow' : 'deny', csrf: csrf ? 'allow' : 'deny' };\n}\n\n// Учебные проверки, не production-конфигурация:\ncheckRequest({\n  origin: 'https://app.example.test',\n  credentials: true,\n  method: 'POST',\n  csrfToken: 'token-from-session',\n}); // { cors: 'allow', csrf: 'allow' }\n\ncheckRequest({\n  origin: 'https://evil.example',\n  credentials: true,\n  method: 'POST',\n  csrfToken: undefined,\n}); // { cors: 'deny', csrf: 'deny' }
\n

Строка token-from-session здесь условна. В реальном приложении token должен быть непредсказуемым, связанным с сессией и сгенерированным безопасным источником случайности. Сравнение выполняет сервер до побочного эффекта. Не переносите этот код в middleware без проверки cookie policy, proxy и framework-контракта.

\n

Preflight полезен для диагностики формы запроса. Custom header вроде X-CSRF-Token или нестандартный content type часто вызывает OPTIONS-проверку. Но preflight не подтверждает token, сессию и право пользователя. Он проверяет, разрешает ли CORS-политика указанный origin, method и header. Поэтому нельзя считать preflight самостоятельной CSRF-защитой.

\n
\"Схема:
CORS открывает JavaScript доступ к ответу только для точного origin. CSRF отдельно требует доказательство до изменения данных. Иллюстрация учебная: она не показывает реальный браузерный запуск или лог API.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
В консоли CORS error при cookie-запросеWildcard или отсутствует точный originСверить Origin, Access-Control-Allow-Origin, credentials и Vary: OriginОставить allow-list точных origin; не подставлять любое входное значение
OPTIONS проходит, POST меняет данные без tokenPreflight ошибочно приняли за CSRF-защитуОтправить form-shaped POST без custom header и проверить серверный ответПроверять CSRF-token до mutation для всех state-changing методов
403 после добавления tokenToken не связан с текущей сессией или не доходит через proxyПроверить источник token, cookie, заголовок, нормализацию и точку отказаИсправить передачу и проверку; не отключать middleware целиком
Данные не читаются, но запись всё равно меняетсяCORS скрывает response, но сервер принимает запрос по cookieСмотреть server log и статус actual request отдельно от сообщения браузераДобавить серверный CSRF-контроль и authorization
\n

Порядок действий

\n
  1. Зафиксируйте страницу-источник, API URL, scheme, host и port. Не называйте два адреса одним «сайтом» без проверки origin.
  2. Повторите запрос без изменений конфигурации. Сохраните method, content type, request headers, response status и серверный log.
  3. Проверьте CORS отдельно. Для credentialed запроса нужен точный разрешённый origin и согласованный credentials response. Для публичного non-credentialed ресурса wildcard может быть уместен, но это другой контракт.
  4. Перечислите все методы и endpoints, которые меняют состояние. Для каждого укажите источник CSRF-token и место проверки до mutation.
  5. Проверьте отрицательный путь: POST из чужого origin, POST без token, неверный token и form-shaped POST без preflight должны получить отказ или не изменить состояние.
  6. Проверьте authorization после CSRF. Валидный token не даёт пользователю право менять чужой ресурс.
  7. Проверьте реальный browser flow с двумя контролируемыми origin. Учебная функция выше годится для проверки логики ветвлений, но не заменяет integration test.
  8. Добавьте регрессионные проверки и наблюдение за отказами. В журнале не записывайте полный token и cookie.
\n

Ограничения

\n

Эта схема предполагает cookie-based authentication и браузерный клиент. Она не описывает OAuth bearer token в заголовке, webhook, native app или server-to-server вызов. Для таких клиентов модель угроз и способ доказать полномочия будут другими.

\n

SameSite помогает ограничить отправку cookie, но не отменяет серверную проверку. Его результат зависит от атрибутов cookie, браузера, контекста навигации и окружения. Origin может отсутствовать или иметь значение null. Proxy может изменить набор видимых заголовков. Эти случаи нужно включить в отдельную политику, а не молча считать безопасными.

\n

XSS в доверенном origin может обойти многие CSRF-меры, потому что вредоносный код действует внутри разрешённого контекста. Поэтому исправление CSRF не заменяет защиту от XSS, управление cookie и контроль прав.

\n

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

\n

Сценарий готов, если команда может предъявить для одного state-changing endpoint четыре независимых доказательства: точный allow-list origin, ожидаемый CORS response, проверку CSRF-token до mutation и отказ при чужом origin или неверном token. В реальном browser test легитимный запрос читает response и меняет только разрешённый ресурс. Отрицательные запросы получают 403 или эквивалентный отказ, а состояние не меняется. Ни один из этих результатов нельзя заменять одним сообщением CORS в консоли.

\n

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

\n" }