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

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

\n

Разберём один cookie-аутентифицированный endpoint PATCH /profile. Для него нужно ответить на три разных вопроса: какой origin отправил запрос, может ли JavaScript этого origin прочитать response и доказал ли запрос право изменить состояние. CORS отвечает только на второй вопрос. CSRF-защита отвечает на третий. Аутентификация и authorization остаются отдельными серверными проверками.

\n

Сначала зафиксируйте границу

\n

Origin — это комбинация scheme, host и port. Поэтому https://app.example.test и http://app.example.test различаются по scheme, а https://app.example.test:8443 — по port. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin. Проверка host.endsWith('example.test') опасна: строка может пропустить неподконтрольный поддомен или вовсе другой домен.

\n

Запишите для одной операции полный source origin, URL API, method, content type, режим credentials и имена заголовков. Сообщение в консоли не показывает всего пути. Нужно отличить отказ preflight, отправленный actual request с недоступным для JavaScript ответом и серверный отказ до mutation.

\n
\"Контракт
Один запрос проходит через независимые границы: browser origin, CORS-доступ к response, CSRF-доказательство и authorization. Схема учебная и не заменяет трассировку конкретного браузера или API.
\n

Что именно делает CORS

\n

CORS, Cross-Origin Resource Sharing, — протокол поверх HTTP, которым сервер сообщает браузеру, можно ли передать cross-origin response коду страницы. Если клиент вызывает fetch(url, { credentials: 'include' }), credentials mode влияет на CORS-контракт: сервер должен вернуть точный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * нельзя использовать как разрешённый origin для credentialed response.

\n

Когда разрешённый список вычисляется динамически, response должен различаться по заголовку Origin; для промежуточного кеша это означает Vary: Origin. Нельзя без проверки копировать входной Origin в Access-Control-Allow-Origin. Сначала сравните его с фиксированным allow-list, затем сформируйте заголовок. Allow-list должен описывать конкретную причину доступа, а не все поддомены компании.

\n

CORS не делает пользователя авторизованным и не защищает данные от запроса, который сервер всё равно выполнит. Браузер может скрыть response от JavaScript, но запрос уже мог дойти до API. Поэтому проверку CORS-ответа нельзя ставить вместо authentication, authorization или CSRF-контроля.

\n

Почему preflight не является CSRF-защитой

\n

Для запроса с нестандартной формой браузер часто сначала отправляет OPTIONS. Например, PATCH, JSON-тело и заголовок X-CSRF-Token приводят к preflight. В нём браузер сообщает origin, будущий method и имена заголовков:

\n
OPTIONS /profile HTTP/1.1\nHost: api.example.test\nOrigin: https://app.example.test\nAccess-Control-Request-Method: PATCH\nAccess-Control-Request-Headers: content-type,x-csrf-token\n\nHTTP/1.1 204 No Content\nAccess-Control-Allow-Origin: https://app.example.test\nAccess-Control-Allow-Credentials: true\nAccess-Control-Allow-Methods: PATCH\nAccess-Control-Allow-Headers: Content-Type, X-CSRF-Token\nVary: Origin
\n

Успешный OPTIONS означает только, что политика CORS разрешила форму следующего запроса. Он не сравнивает token с сессией и не знает, имеет ли пользователь право редактировать профиль. Более того, обычная HTML-форма может отправить state-changing POST без custom header и без preflight. Серверу нельзя делать вывод «раз preflight не было, значит запрос безопасен».

\n

Важна и обратная сторона: custom header создаёт удобную границу для API-клиента, потому что чужая страница не может произвольно добавить его к cross-origin запросу без прохождения CORS. Но это часть общей схемы, а не единственная причина доверять запросу. Заголовок нужно проверить на сервере, связать с сессией и выполнить проверку до изменения данных.

\n

Учебный endpoint: решение до mutation

\n

Ниже — псевдо-JavaScript для сервера. trustedOrigins и имя заголовка — проектные значения. constantTimeEqual должна быть безопасной реализацией сравнения из выбранного фреймворка или криптографической библиотеки; функция в примере не реализована намеренно. Порядок важнее названий: сначала границы запроса, затем CSRF-доказательство, затем право пользователя, и только после этого побочный эффект.

\n
const trustedOrigins = new Set([\n  'https://app.example.test',\n]);\n\nfunction corsHeaders(origin) {\n  if (!trustedOrigins.has(origin)) return null;\n  return {\n    'Access-Control-Allow-Origin': origin,\n    'Access-Control-Allow-Credentials': 'true',\n    'Vary': 'Origin',\n  };\n}\n\nfunction patchProfile(request, session) {\n  const origin = request.headers.get('Origin');\n  const token = request.headers.get('X-CSRF-Token');\n\n  if (!trustedOrigins.has(origin)) {\n    return deny(403, 'origin-not-allowed');\n  }\n  if (!token || !constantTimeEqual(token, session.csrfToken)) {\n    return deny(403, 'csrf-check-failed');\n  }\n  if (!session.user.can('profile:update')) {\n    return deny(403, 'not-authorized');\n  }\n\n  return applyProfileChange(request.body);\n}
\n

Код не является готовым middleware. Реальный компонент должен сам получить session после проверки cookie, ограничить размер и схему тела, корректно обработать повтор и записать безопасную причину отказа. Полный CSRF-токен и cookie нельзя класть в логи. Если используется stateful-сессия, OWASP рекомендует synchronizer token: сервер создаёт непредсказуемый токен, хранит его в сессии и сравнивает со значением из заголовка или формы. Для stateless-схемы нужен подходящий double-submit вариант, причём наивное сравнение двух cookie без привязки к сессии имеет отдельные риски.

\n

Воспроизводимая проверка через curl

\n

curl не применяет browser policy и потому не может сам показать, что JavaScript увидит response. Он полезен для проверки фактических HTTP-заголовков и серверного статуса. Запускайте команды против тестового API, подставляя только тестовые значения SESSION_COOKIE и CSRF_TOKEN. В production-сессию их не копируйте.

\n
export API='https://api.example.test/profile'\nexport SESSION_COOKIE='test-session-cookie'\nexport CSRF_TOKEN='test-csrf-token'\n\n# 1. Разрешённый preflight: ожидаем 204 и узкий набор разрешений.\ncurl -i -X OPTIONS \"$API\" \\\n  -H 'Origin: https://app.example.test' \\\n  -H 'Access-Control-Request-Method: PATCH' \\\n  -H 'Access-Control-Request-Headers: content-type,x-csrf-token'\n\n# 2. Разрешённый actual request: ожидаем успешный ответ и одно изменение.\ncurl -i -X PATCH \"$API\" \\\n  -H 'Origin: https://app.example.test' \\\n  -H 'Content-Type: application/json' \\\n  -H \"X-CSRF-Token: $CSRF_TOKEN\" \\\n  --cookie \"session=$SESSION_COOKIE\" \\\n  --data '{\"displayName\":\"Test User\"}'\n\n# 3. Чужой origin и отсутствие token: ожидаем отказ и отсутствие mutation.\ncurl -i -X PATCH \"$API\" \\\n  -H 'Origin: https://evil.example' \\\n  -H 'Content-Type: application/json' \\\n  --cookie \"session=$SESSION_COOKIE\" \\\n  --data '{\"displayName\":\"Unexpected Change\"}'
\n

В третьей проверке недостаточно увидеть 403. Сверьте состояние профиля и серверный log: обработчик не должен вызвать applyProfileChange. Добавьте ещё два отрицательных запуска — с разрешённым origin, но без token, и с token от другой тестовой сессии. Для каждого заранее запишите ожидаемый статус и признак отсутствия изменения.

\n

Матрица симптомов

\n
НаблюдениеВероятная границаЧто собратьСледующее действие
OPTIONS отклонёнCORS не разрешил method или headerOrigin, Access-Control-Request-Method, Access-Control-Request-Headers, статусСузить и согласовать фактический allow-list; CSRF middleware не менять
OPTIONS успешен, PATCH получает 403Серверный CSRF или authorizationПричина отказа до mutation, наличие token, сессия и permissionРазделить token check и право на объект; не добавлять новый CORS origin
PATCH изменяет данные, но JavaScript видит CORS errorCORS response оформлен неправильно после actual requestServer status, response headers и факт измененияИсправить response contract, сохранив CSRF и authorization
Всё работает без custom headerВозможно, endpoint принимает простой form-shaped запрос по cookiePOST/PUT-путь без preflight и без CSRF proof в тестовой сессииЗакрыть каждый state-changing путь серверной проверкой
Разрешён чужой поддоменСлабое сравнение host или динамическое эхо originПолные origin со scheme и port, конфигурацию allow-listСравнивать нормализованный origin с фиксированными значениями
\n

Порядок проверки одного endpoint

\n
  1. Выберите одну state-changing операцию и зафиксируйте цену ошибки: потеря видимости ответа, отклонённая запись или реально выполненное изменение.
  2. Снимите точный request contract: source origin, URL, method, content type, credentials и заголовки. Слово «фронтенд» не заменяет эти значения.
  3. Проверьте origin как scheme/host/port tuple. Отдельно протестируйте другой scheme, port и похожее имя домена.
  4. Проверьте CORS response и preflight. Для динамического origin сверяйте также Vary: Origin и поведение кеша.
  5. Проверьте CSRF proof до mutation: отсутствующий, неверный и принадлежащий другой сессии token должны завершаться отказом.
  6. Проверьте authorization после CSRF: валидный token не даёт пользователю права менять чужой профиль.
  7. Повторите happy path в реальном тестовом браузере, а не только через curl. Сопоставьте DevTools Network с серверным log.
  8. Закрепите положительный и отрицательные сценарии интеграционным тестом. Не записывайте в fixture реальные cookie, token или персональные данные.
\n

Ограничения применимости

\n

Схема относится к браузерному клиенту, который использует cookie или другую автоматически прикладываемую credential. Для bearer token, который JavaScript явно кладёт в заголовок и не получает из cookie, классическая CSRF-модель обычно отличается; это не отменяет проверки authentication, authorization и защиты от XSS. Webhook и server-to-server вызовам нужен свой контракт подписи, replay-защиты и прав.

\n

SameSite ограничивает отправку cookie, но его итог зависит от атрибутов cookie, контекста навигации, браузера и политики third-party cookies. Его разумно рассматривать как слой защиты, а не как повод удалить серверную проверку. Origin иногда отсутствует или имеет значение null; proxy может изменить наблюдаемую картину; redirect может привести к другому origin. Для этих случаев нужна явная политика отказа или отдельная проверка, а не молчаливое разрешение.

\n

GET не должен менять состояние. Если legacy endpoint нарушает это правило, его нельзя считать безопасным только из-за метода: OWASP рекомендует защитить такой ресурс от CSRF и планировать миграцию. XSS на доверенном origin также выходит за рамки CORS и может действовать изнутри приложения. Поэтому нужны отдельные меры для XSS, cookie policy, CSP, аудита и ограничения прав.

\n

Учебный код не моделирует конкретный framework, браузерный кеш, все правила cookies или сетевые proxy. Его результат — проверяемый порядок условий, а не доказательство безопасности production API. Доказательством служит повторяемый browser/integration тест вместе с server-side evidence на контролируемой тестовой сессии.

\n

Критерий готовности

\n

Один endpoint можно считать проверенным, если для него названы разрешённые origin и credentials-контракт, воспроизведён preflight либо объяснено его отсутствие, а server log показывает проверку CSRF до побочного эффекта и authorization после неё. Тестовая сессия должна успешно прочитать разрешённый response и изменить только свой профиль. Другой origin, пустой token, token другой сессии и попытка изменить чужой объект должны завершаться отказом без изменения состояния. Один заголовок CORS или один успешный ручной запрос этих доказательств не заменяет.

\n

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

\n" }