{ "index": 173, "slug": "editorial-2023-03-mechanism-csrf-cors", "title": "CSRF и CORS: где заканчивается доступ к ответу и начинается защита действия", "excerpt": "Разбираем на одном cookie-аутентифицированном endpoint, чем отличаются origin, CORS, preflight и CSRF-проверка, как воспроизвести запрос через curl и где заканчиваются гарантии каждого слоя.", "contentHtml": "
После переноса интерфейса на новый origin команда видит в консоли CORS error. Один запрос «не читается» из JavaScript, другой получает 403, а третья операция, судя по журналу API, всё-таки меняет данные. Эти симптомы легко свести к одной настройке и открыть Access-Control-Allow-Origin: * или отключить CSRF-проверку. Так можно одновременно разрешить лишнему сайту читать ответы и пропустить изменение, отправленное браузером с чужой страницы.
Правильная диагностика начинается с разделения вопросов. Origin определяет контекст страницы. CORS сообщает браузеру, можно ли коду этой страницы прочитать cross-origin response. Preflight проверяет, разрешена ли форма будущего запроса. CSRF-защита решает на сервере, есть ли у state-changing запроса доказательство доверенного намерения. Ни один из этих слоёв не заменяет authentication и authorization.
\nСообщение браузера не отвечает на главный вопрос: запрос не ушёл, ушёл и был отклонён сервером или сервер выполнил действие, но браузер скрыл ответ от JavaScript. Для одного проблемного endpoint зафиксируйте URL страницы, URL API, method, content type, режим credentials, request headers, status и наличие побочного эффекта. Проверяйте одну и ту же тестовую сессию, иначе смена cookie создаст ложную причину.
\nПусть страница открыта с https://app.example.test, а API находится на https://api.example.test. Клиент отправляет PATCH /profile с credentials: 'include', JSON и заголовком X-CSRF-Token. Для браузера это cross-origin запрос: host отличается, хотя оба имени находятся в одном условном домене. Если сервер не разрешил method или заголовок в preflight, actual request обычно не начнётся. Если preflight прошёл, token ещё не проверен. Если token прошёл, право менять конкретный профиль всё ещё нужно проверять отдельно.
В модели Web Origin Concept origin — это комбинация scheme, host и port. Поэтому https://app.example.test, http://app.example.test и https://app.example.test:8443 — разные origins. Path, query и fragment в origin не входят: https://app.example.test/a и https://app.example.test/b имеют один origin.
Из этого не следует, что любой поддомен безопасен. Проверка вроде host.endsWith('.example.test') расширяет доверие на каждый хост под доменом, включая забытый в DNS или отданный внешнему провайдеру. Для credentialed API храните явный allow-list полных origin и сравнивайте сериализованное значение целиком. https://admin.example.test может быть разрешённым источником для одного ресурса, но это не даёт его коду бизнес-права на любой объект.
CORS — протокол обмена между user agent и сервером. Сервер возвращает Access-Control-Allow-Origin, а браузер использует его при решении, можно ли отдать response коду страницы. Это не сетевой firewall: серверный endpoint может получить простой cross-origin запрос даже тогда, когда JavaScript не сможет прочитать ответ. Поэтому CORS-заголовок нельзя использовать как единственную защиту операции.
Для запроса с credentials: 'include' сервер должен вернуть точный origin, например Access-Control-Allow-Origin: https://app.example.test, и Access-Control-Allow-Credentials: true. Пара * плюс true не разрешает credentialed response: Fetch Standard прямо считает такой вариант недопустимым. Если origin выбирается динамически из allow-list, добавьте Vary: Origin, чтобы промежуточный cache не отдал ответ, подготовленный для другого источника.
Важна и обратная сторона: response с ошибкой тоже может быть скрыт от JavaScript. Поэтому «в консоли CORS error» не доказывает, что сервер не выполнил side effect. Сверяйте browser Network, status на API boundary и запись в журнале операции.
\nБраузер делает preflight, когда запрос не укладывается в CORS safelist. Типичные причины — method PATCH, JSON content type или custom header. Preflight — это OPTIONS с Origin, Access-Control-Request-Method и, при необходимости, Access-Control-Request-Headers. Сервер отвечает разрешёнными method и header, после чего браузер может отправить actual request.
Preflight не содержит cookie сессии: в Fetch Standard credentials mode для него — same-origin. Он не сравнивает CSRF token, не знает пользователя и не проверяет право на профиль. Более того, обычная HTML-форма может отправить простой POST без preflight. Значит, отсутствие строки OPTIONS в логах не означает отсутствие CSRF-риска.
Практический контракт для нашего endpoint выглядит узко: разрешить только https://app.example.test, method PATCH и headers content-type, x-csrf-token. Не следует отвечать «разрешаю всё», если клиенту нужен один method и один заголовок.
Сначала воспроизведите preflight без реальной cookie. Команда проверяет только CORS-контракт; она не доказывает, что actual request будет принят.
\ncurl -i -X OPTIONS https://api.example.test/profile -H 'Origin: https://app.example.test' -H 'Access-Control-Request-Method: PATCH' -H 'Access-Control-Request-Headers: content-type, x-csrf-token'\nВ ожидаемом ответе должны быть статус 2xx и совместимые значения: exact Access-Control-Allow-Origin, Access-Control-Allow-Methods: PATCH и Access-Control-Allow-Headers с нужными именами. Для credentialed flow нужен также Access-Control-Allow-Credentials: true. Конкретный набор дополнительных заголовков зависит от вашего API.
Затем отправьте actual request в тестовой среде с тестовыми значениями. Не подставляйте production cookie или token в терминал, историю shell и статью.
\ncurl -i -X PATCH https://api.example.test/profile -H 'Origin: https://app.example.test' -H 'Content-Type: application/json' -H 'X-CSRF-Token: TEST_TOKEN_FROM_THE_SAME_SESSION' -H 'Cookie: session=TEST_SESSION' --data '{\"displayName\":\"Ada\"}'\ncurl не моделирует браузерную CORS-блокировку: он покажет ответ независимо от CORS headers. Это полезно для проверки API и side effect, но финальный вывод о поведении frontend делайте по browser test или HAR. Если curl с плохим origin всё равно изменяет запись, это не «ошибка CORS», а отсутствие серверной проверки, которую нельзя компенсировать заголовком ответа.
CSRF возникает, когда браузер пользователя автоматически прикладывает authentication cookie к запросу, инициированному чужим сайтом. Сервер видит действительную сессию, но не отличает намеренное действие от подделанного. Для state-changing endpoint обычно применяют synchronizer token, signed double-submit cookie, проверку Origin/Referer или комбинацию методов согласно threat model и возможностям фреймворка. GET и другие safe-methods не должны менять состояние.
\nДля API с cookie-аутентификацией custom header удобен тем, что чужой origin не может прочитать token из приложения и выставить такой header обычной формой. Но этот подход остаётся безопасным только вместе с серверной проверкой token и узким CORS allow-list. Наличие заголовка в OPTIONS не доказывает валидность его значения в PATCH.
\nconst allowedOrigins = new Set([\n 'https://app.example.test',\n]);\n\nfunction handleProfilePatch(request, session) {\n const origin = request.headers.get('Origin');\n const token = request.headers.get('X-CSRF-Token');\n\n if (!allowedOrigins.has(origin)) {\n return deny(403, 'origin-not-allowed');\n }\n if (!token || !constantTimeEqual(token, session.csrfToken)) {\n return deny(403, 'csrf-token-invalid');\n }\n if (!session.user.can('profile:update')) {\n return deny(403, 'forbidden');\n }\n\n return updateProfile(request.body);\n}\nЭто псевдокод границы, а не готовый middleware. Реализация должна определить генерацию и срок жизни token, привязку к сессии, rotation, logout, способ constant-time сравнения, лимиты и журналирование. Проверки origin и token выполняйте до updateProfile. Даже принятый token не даёт пользователю доступа к чужому профилю: это уже authorization.
| Наблюдение | Что оно подтверждает | Чего оно не подтверждает | Следующая проверка |
|---|---|---|---|
| OPTIONS вернул 204 | Preflight-контракт принят браузером для этой формы | Cookie, token и право на объект | Отправить actual request и проверить серверный отказ/успех |
| В консоли CORS error | JavaScript не получил доступ к response | Что actual request не выполнил side effect | Сверить status и запись операции на API boundary |
Access-Control-Allow-Origin: * | Возможен публичный CORS для запроса без credentials | Безопасность cookie-сессии и CSRF | Проверить credentials mode и заменить wildcard на exact allow-list, если нужны cookies |
| PATCH с token получил 403 | Одна из серверных проверок отклонила запрос | Какая именно: origin, token или authorization | Развести коды/логи причин и исключить side effect |
| POST-форма не вызвала OPTIONS | Запрос прошёл без preflight | Что операция безопасна | Проверить CSRF-защиту actual request |
| curl получил 200 | API ответил этому HTTP-клиенту | Что браузер отдаст response JavaScript | Повторить в браузере с тем же origin и credentials |
Vary: Origin на ответах, проходящих через cache.Cookie delivery зависит не только от CORS. На неё влияют credentials mode, SameSite, Secure, Domain, Path, third-party cookie restrictions, redirects и политика конкретного браузера. Поэтому один Access-Control-Allow-Credentials не доказывает, что cookie ушла. И наоборот, отправленная cookie не означает, что JavaScript прочитает response.
SameSite — полезный слой снижения риска, но не универсальная замена CSRF token. OWASP допускает узкие случаи, где его достаточно, только при одновременном выполнении нескольких условий: приложение не делит registrable domain с недоверенными хостами, GET не меняет состояние, cookie настроена строго и есть дополнительная проверка origin/referrer. В остальных архитектурах относитесь к SameSite как к defense in depth.
Origin/Referer могут отсутствовать или иметь значение null в отдельных privacy-контекстах, поэтому policy должна явно описывать такие запросы. За прокси target origin нужно брать из доверенной конфигурации или корректно обработанных forwarded headers, а не слепо сравнивать с внутренним host. XSS на доверенном origin, утёкшая сессия, вредоносный service-to-service клиент, authorization flaw, rate limit и CSP лежат за пределами CSRF/CORS и требуют собственных controls.
Разбор можно считать завершённым, когда для endpoint названы разрешённые origins и credentials mode, воспроизводится preflight или объяснено его отсутствие, actual request проверяет CSRF proof до side effect, а authorization отделена от этой проверки. Есть тесты для правильного и неправильного origin, отсутствующего и чужого token, запрещённых method/header и cache-варианта. Если команда видит только заголовок CORS или только зелёный unit test token, контракт ещё не доказан целиком.
\nVary: Origin.Origin.