Files
progcode/editorial/agent-rewrites/173.json
T

8 lines
21 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>"
}