8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 173,
|
||
"slug": "editorial-2023-03-mechanism-csrf-cors",
|
||
"title": "CSRF и CORS: где заканчивается доступ к ответу и начинается защита действия",
|
||
"excerpt": "Разбираем на одном cookie-аутентифицированном endpoint, чем отличаются origin, CORS, preflight и CSRF-проверка, как воспроизвести запрос через curl и где заканчиваются гарантии каждого слоя.",
|
||
"contentHtml": "<p>После переноса интерфейса на новый origin команда видит в консоли <code>CORS error</code>. Один запрос «не читается» из JavaScript, другой получает <code>403</code>, а третья операция, судя по журналу API, всё-таки меняет данные. Эти симптомы легко свести к одной настройке и открыть <code>Access-Control-Allow-Origin: *</code> или отключить CSRF-проверку. Так можно одновременно разрешить лишнему сайту читать ответы и пропустить изменение, отправленное браузером с чужой страницы.</p>\n<p>Правильная диагностика начинается с разделения вопросов. <strong>Origin</strong> определяет контекст страницы. <strong>CORS</strong> сообщает браузеру, можно ли коду этой страницы прочитать cross-origin response. <strong>Preflight</strong> проверяет, разрешена ли форма будущего запроса. <strong>CSRF-защита</strong> решает на сервере, есть ли у state-changing запроса доказательство доверенного намерения. Ни один из этих слоёв не заменяет authentication и authorization.</p>\n<h2>Симптом сначала, заголовок потом</h2>\n<p>Сообщение браузера не отвечает на главный вопрос: запрос не ушёл, ушёл и был отклонён сервером или сервер выполнил действие, но браузер скрыл ответ от JavaScript. Для одного проблемного endpoint зафиксируйте URL страницы, URL API, method, content type, режим credentials, request headers, status и наличие побочного эффекта. Проверяйте одну и ту же тестовую сессию, иначе смена cookie создаст ложную причину.</p>\n<p>Пусть страница открыта с <code>https://app.example.test</code>, а API находится на <code>https://api.example.test</code>. Клиент отправляет <code>PATCH /profile</code> с <code>credentials: 'include'</code>, JSON и заголовком <code>X-CSRF-Token</code>. Для браузера это cross-origin запрос: host отличается, хотя оба имени находятся в одном условном домене. Если сервер не разрешил method или заголовок в preflight, actual request обычно не начнётся. Если preflight прошёл, token ещё не проверен. Если token прошёл, право менять конкретный профиль всё ещё нужно проверять отдельно.</p>\n<h2>Origin — не домен и не право доступа</h2>\n<p>В модели Web Origin Concept origin — это комбинация <em>scheme</em>, host и port. Поэтому <code>https://app.example.test</code>, <code>http://app.example.test</code> и <code>https://app.example.test:8443</code> — разные origins. Path, query и fragment в origin не входят: <code>https://app.example.test/a</code> и <code>https://app.example.test/b</code> имеют один origin.</p>\n<p>Из этого не следует, что любой поддомен безопасен. Проверка вроде <code>host.endsWith('.example.test')</code> расширяет доверие на каждый хост под доменом, включая забытый в DNS или отданный внешнему провайдеру. Для credentialed API храните явный allow-list полных origin и сравнивайте сериализованное значение целиком. <code>https://admin.example.test</code> может быть разрешённым источником для одного ресурса, но это не даёт его коду бизнес-права на любой объект.</p>\n<figure><img src=\"/assets/editorial/2023/csrf-cors-2023-mechanism-layers.svg\" alt=\"Схема из трёх слоёв: origin задаёт контекст, CORS регулирует чтение ответа, CSRF проверяет изменение данных на сервере\" /><figcaption>Одна cross-origin операция проходит несколько независимых вопросов. Стрелки на схеме не означают автоматическую передачу credentials: cookie и token подчиняются собственным правилам.</figcaption></figure>\n<h2>CORS управляет чтением ответа</h2>\n<p>CORS — протокол обмена между user agent и сервером. Сервер возвращает <code>Access-Control-Allow-Origin</code>, а браузер использует его при решении, можно ли отдать response коду страницы. Это не сетевой firewall: серверный endpoint может получить простой cross-origin запрос даже тогда, когда JavaScript не сможет прочитать ответ. Поэтому CORS-заголовок нельзя использовать как единственную защиту операции.</p>\n<p>Для запроса с <code>credentials: 'include'</code> сервер должен вернуть точный origin, например <code>Access-Control-Allow-Origin: https://app.example.test</code>, и <code>Access-Control-Allow-Credentials: true</code>. Пара <code>*</code> плюс <code>true</code> не разрешает credentialed response: Fetch Standard прямо считает такой вариант недопустимым. Если origin выбирается динамически из allow-list, добавьте <code>Vary: Origin</code>, чтобы промежуточный cache не отдал ответ, подготовленный для другого источника.</p>\n<p>Важна и обратная сторона: response с ошибкой тоже может быть скрыт от JavaScript. Поэтому «в консоли CORS error» не доказывает, что сервер не выполнил side effect. Сверяйте browser Network, status на API boundary и запись в журнале операции.</p>\n<h2>Preflight проверяет форму, а не намерение</h2>\n<p>Браузер делает preflight, когда запрос не укладывается в CORS safelist. Типичные причины — method <code>PATCH</code>, JSON content type или custom header. Preflight — это <code>OPTIONS</code> с <code>Origin</code>, <code>Access-Control-Request-Method</code> и, при необходимости, <code>Access-Control-Request-Headers</code>. Сервер отвечает разрешёнными method и header, после чего браузер может отправить actual request.</p>\n<p>Preflight не содержит cookie сессии: в Fetch Standard credentials mode для него — <code>same-origin</code>. Он не сравнивает CSRF token, не знает пользователя и не проверяет право на профиль. Более того, обычная HTML-форма может отправить простой <code>POST</code> без preflight. Значит, отсутствие строки <code>OPTIONS</code> в логах не означает отсутствие CSRF-риска.</p>\n<p>Практический контракт для нашего endpoint выглядит узко: разрешить только <code>https://app.example.test</code>, method <code>PATCH</code> и headers <code>content-type, x-csrf-token</code>. Не следует отвечать «разрешаю всё», если клиенту нужен один method и один заголовок.</p>\n<h2>Рабочий repro через curl</h2>\n<p>Сначала воспроизведите preflight без реальной cookie. Команда проверяет только CORS-контракт; она не доказывает, что actual request будет принят.</p>\n<pre><code>curl -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'</code></pre>\n<p>В ожидаемом ответе должны быть статус <code>2xx</code> и совместимые значения: exact <code>Access-Control-Allow-Origin</code>, <code>Access-Control-Allow-Methods: PATCH</code> и <code>Access-Control-Allow-Headers</code> с нужными именами. Для credentialed flow нужен также <code>Access-Control-Allow-Credentials: true</code>. Конкретный набор дополнительных заголовков зависит от вашего API.</p>\n<p>Затем отправьте actual request в тестовой среде с тестовыми значениями. Не подставляйте production cookie или token в терминал, историю shell и статью.</p>\n<pre><code>curl -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\"}'</code></pre>\n<p><code>curl</code> не моделирует браузерную CORS-блокировку: он покажет ответ независимо от CORS headers. Это полезно для проверки API и side effect, но финальный вывод о поведении frontend делайте по browser test или HAR. Если curl с плохим origin всё равно изменяет запись, это не «ошибка CORS», а отсутствие серверной проверки, которую нельзя компенсировать заголовком ответа.</p>\n<h2>CSRF: доказательство до побочного эффекта</h2>\n<p>CSRF возникает, когда браузер пользователя автоматически прикладывает authentication cookie к запросу, инициированному чужим сайтом. Сервер видит действительную сессию, но не отличает намеренное действие от подделанного. Для state-changing endpoint обычно применяют synchronizer token, signed double-submit cookie, проверку Origin/Referer или комбинацию методов согласно threat model и возможностям фреймворка. GET и другие safe-methods не должны менять состояние.</p>\n<p>Для API с cookie-аутентификацией custom header удобен тем, что чужой origin не может прочитать token из приложения и выставить такой header обычной формой. Но этот подход остаётся безопасным только вместе с серверной проверкой token и узким CORS allow-list. Наличие заголовка в OPTIONS не доказывает валидность его значения в PATCH.</p>\n<pre><code>const 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}</code></pre>\n<p>Это псевдокод границы, а не готовый middleware. Реализация должна определить генерацию и срок жизни token, привязку к сессии, rotation, logout, способ constant-time сравнения, лимиты и журналирование. Проверки origin и token выполняйте до <code>updateProfile</code>. Даже принятый token не даёт пользователю доступа к чужому профилю: это уже authorization.</p>\n<h2>Матрица диагностики</h2>\n<table><thead><tr><th>Наблюдение</th><th>Что оно подтверждает</th><th>Чего оно не подтверждает</th><th>Следующая проверка</th></tr></thead><tbody><tr><td>OPTIONS вернул 204</td><td>Preflight-контракт принят браузером для этой формы</td><td>Cookie, token и право на объект</td><td>Отправить actual request и проверить серверный отказ/успех</td></tr><tr><td>В консоли CORS error</td><td>JavaScript не получил доступ к response</td><td>Что actual request не выполнил side effect</td><td>Сверить status и запись операции на API boundary</td></tr><tr><td><code>Access-Control-Allow-Origin: *</code></td><td>Возможен публичный CORS для запроса без credentials</td><td>Безопасность cookie-сессии и CSRF</td><td>Проверить credentials mode и заменить wildcard на exact allow-list, если нужны cookies</td></tr><tr><td>PATCH с token получил 403</td><td>Одна из серверных проверок отклонила запрос</td><td>Какая именно: origin, token или authorization</td><td>Развести коды/логи причин и исключить side effect</td></tr><tr><td>POST-форма не вызвала OPTIONS</td><td>Запрос прошёл без preflight</td><td>Что операция безопасна</td><td>Проверить CSRF-защиту actual request</td></tr><tr><td>curl получил 200</td><td>API ответил этому HTTP-клиенту</td><td>Что браузер отдаст response JavaScript</td><td>Повторить в браузере с тем же origin и credentials</td></tr></tbody></table>\n<h2>Пошаговая проверка в тестовой среде</h2>\n<ol><li>Выберите один state-changing endpoint и запишите ожидаемый side effect: например, изменение только поля профиля тестового пользователя.</li><li>Снимите контракт запроса: source origin, target origin, method, content type, credentials mode и точные имена headers. Проверяйте origin как полную строку, а не как suffix host.</li><li>Выполните preflight с разрешённым и запрещённым method/header. Убедитесь, что отказ не меняет состояние.</li><li>Повторите actual request с корректным token, без token и с token от другой тестовой сессии. Во всех отрицательных случаях проверьте отсутствие side effect.</li><li>Повторите запрос с другим scheme, port и неизвестным subdomain. Не делайте вывод только по CORS response: проверьте серверное решение.</li><li>Сопоставьте DevTools или HAR, API-лог и запись в базе/аудите. Особенно проверьте случай «браузер показал CORS error, но сервер вернул 2xx».</li><li>После изменения allow-list очистите или дождитесь истечения preflight cache и повторите сценарии. Для динамического origin проверьте <code>Vary: Origin</code> на ответах, проходящих через cache.</li><li>Закрепите контракт интеграционным тестом и назначьте владельца списка origins. Новый frontend origin добавляйте после проверки архитектуры и границ данных endpoint.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Cookie delivery зависит не только от CORS. На неё влияют credentials mode, <code>SameSite</code>, <code>Secure</code>, <code>Domain</code>, <code>Path</code>, third-party cookie restrictions, redirects и политика конкретного браузера. Поэтому один <code>Access-Control-Allow-Credentials</code> не доказывает, что cookie ушла. И наоборот, отправленная cookie не означает, что JavaScript прочитает response.</p>\n<p><code>SameSite</code> — полезный слой снижения риска, но не универсальная замена CSRF token. OWASP допускает узкие случаи, где его достаточно, только при одновременном выполнении нескольких условий: приложение не делит registrable domain с недоверенными хостами, GET не меняет состояние, cookie настроена строго и есть дополнительная проверка origin/referrer. В остальных архитектурах относитесь к SameSite как к defense in depth.</p>\n<p>Origin/Referer могут отсутствовать или иметь значение <code>null</code> в отдельных privacy-контекстах, поэтому policy должна явно описывать такие запросы. За прокси target origin нужно брать из доверенной конфигурации или корректно обработанных forwarded headers, а не слепо сравнивать с внутренним host. XSS на доверенном origin, утёкшая сессия, вредоносный service-to-service клиент, authorization flaw, rate limit и CSP лежат за пределами CSRF/CORS и требуют собственных controls.</p>\n<h2>Критерий готовности endpoint</h2>\n<p>Разбор можно считать завершённым, когда для endpoint названы разрешённые origins и credentials mode, воспроизводится preflight или объяснено его отсутствие, actual request проверяет CSRF proof до side effect, а authorization отделена от этой проверки. Есть тесты для правильного и неправильного origin, отсутствующего и чужого token, запрещённых method/header и cache-варианта. Если команда видит только заголовок CORS или только зелёный unit test token, контракт ещё не доказан целиком.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://fetch.spec.whatwg.org/#http-cors-protocol\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard, HTTP-network-or-cache fetch и CORS protocol</a> — правила response headers, credentials, preflight и <code>Vary: Origin</code>.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6454.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6454: The Web Origin Concept</a> — состав origin из scheme, host и port и семантика заголовка <code>Origin</code>.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Cross-Site Request Forgery Prevention Cheat Sheet</a> — token patterns, custom headers, SameSite и проверка Origin/Referer с оговорками.</li></ul>"
|
||
}
|