Files
progcode/editorial/agent-rewrites/174.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": 174,
"slug": "editorial-2023-03-practice-csrf-cors",
"title": "Cookie API и виджет: как не перепутать CORS с CSRF",
"excerpt": "Разделяем три проверки для cookie-аутентифицированного API: origin, доступ JavaScript к ответу и право принять изменение данных. На одном PATCH разбираем preflight, CSRF-токен и отрицательные тесты.",
"contentHtml": "<p>Симптом выглядит знакомо: виджет на <code>https://app.example.test</code> вызывает API на <code>https://api.example.test</code>, а браузер показывает <code>CORS error</code>. Профиль не загружается, и команда предлагает добавить <code>Access-Control-Allow-Origin: *</code> или отключить CSRF-проверку. Цена такой правки — не только сломанный интерфейс. Если API принимает cookie автоматически, злоумышленник может добиться изменения данных, даже не умея прочитать ответ.</p>\n<p>Разберём один cookie-аутентифицированный endpoint <code>PATCH /profile</code>. Для него нужно ответить на три разных вопроса: какой origin отправил запрос, может ли JavaScript этого origin прочитать response и доказал ли запрос право изменить состояние. CORS отвечает только на второй вопрос. CSRF-защита отвечает на третий. Аутентификация и authorization остаются отдельными серверными проверками.</p>\n<h2>Сначала зафиксируйте границу</h2>\n<p>Origin — это комбинация scheme, host и port. Поэтому <code>https://app.example.test</code> и <code>http://app.example.test</code> различаются по scheme, а <code>https://app.example.test:8443</code> — по port. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin. Проверка <code>host.endsWith('example.test')</code> опасна: строка может пропустить неподконтрольный поддомен или вовсе другой домен.</p>\n<p>Запишите для одной операции полный source origin, URL API, method, content type, режим credentials и имена заголовков. Сообщение в консоли не показывает всего пути. Нужно отличить отказ preflight, отправленный actual request с недоступным для JavaScript ответом и серверный отказ до mutation.</p>\n<figure><img src=\"/assets/editorial/2023/csrf-cors-2023-practice-contract.svg\" alt=\"Контракт cookie-запроса от app.example.test к api.example.test: сначала origin и CORS отвечают за доступ к response, затем сервер проверяет CSRF-токен и право изменить профиль\" loading=\"lazy\"><figcaption>Один запрос проходит через независимые границы: browser origin, CORS-доступ к response, CSRF-доказательство и authorization. Схема учебная и не заменяет трассировку конкретного браузера или API.</figcaption></figure>\n<h2>Что именно делает CORS</h2>\n<p>CORS, Cross-Origin Resource Sharing, — протокол поверх HTTP, которым сервер сообщает браузеру, можно ли передать cross-origin response коду страницы. Если клиент вызывает <code>fetch(url, { credentials: 'include' })</code>, credentials mode влияет на CORS-контракт: сервер должен вернуть точный разрешённый origin и <code>Access-Control-Allow-Credentials: true</code>. Значение <code>*</code> нельзя использовать как разрешённый origin для credentialed response.</p>\n<p>Когда разрешённый список вычисляется динамически, response должен различаться по заголовку <code>Origin</code>; для промежуточного кеша это означает <code>Vary: Origin</code>. Нельзя без проверки копировать входной <code>Origin</code> в <code>Access-Control-Allow-Origin</code>. Сначала сравните его с фиксированным allow-list, затем сформируйте заголовок. Allow-list должен описывать конкретную причину доступа, а не все поддомены компании.</p>\n<p>CORS не делает пользователя авторизованным и не защищает данные от запроса, который сервер всё равно выполнит. Браузер может скрыть response от JavaScript, но запрос уже мог дойти до API. Поэтому проверку CORS-ответа нельзя ставить вместо authentication, authorization или CSRF-контроля.</p>\n<h2>Почему preflight не является CSRF-защитой</h2>\n<p>Для запроса с нестандартной формой браузер часто сначала отправляет <code>OPTIONS</code>. Например, <code>PATCH</code>, JSON-тело и заголовок <code>X-CSRF-Token</code> приводят к preflight. В нём браузер сообщает origin, будущий method и имена заголовков:</p>\n<pre><code>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</code></pre>\n<p>Успешный <code>OPTIONS</code> означает только, что политика CORS разрешила форму следующего запроса. Он не сравнивает token с сессией и не знает, имеет ли пользователь право редактировать профиль. Более того, обычная HTML-форма может отправить state-changing <code>POST</code> без custom header и без preflight. Серверу нельзя делать вывод «раз preflight не было, значит запрос безопасен».</p>\n<p>Важна и обратная сторона: custom header создаёт удобную границу для API-клиента, потому что чужая страница не может произвольно добавить его к cross-origin запросу без прохождения CORS. Но это часть общей схемы, а не единственная причина доверять запросу. Заголовок нужно проверить на сервере, связать с сессией и выполнить проверку до изменения данных.</p>\n<h2>Учебный endpoint: решение до mutation</h2>\n<p>Ниже — псевдо-JavaScript для сервера. <code>trustedOrigins</code> и имя заголовка — проектные значения. <code>constantTimeEqual</code> должна быть безопасной реализацией сравнения из выбранного фреймворка или криптографической библиотеки; функция в примере не реализована намеренно. Порядок важнее названий: сначала границы запроса, затем CSRF-доказательство, затем право пользователя, и только после этого побочный эффект.</p>\n<pre><code>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}</code></pre>\n<p>Код не является готовым middleware. Реальный компонент должен сам получить session после проверки cookie, ограничить размер и схему тела, корректно обработать повтор и записать безопасную причину отказа. Полный CSRF-токен и cookie нельзя класть в логи. Если используется stateful-сессия, OWASP рекомендует synchronizer token: сервер создаёт непредсказуемый токен, хранит его в сессии и сравнивает со значением из заголовка или формы. Для stateless-схемы нужен подходящий double-submit вариант, причём наивное сравнение двух cookie без привязки к сессии имеет отдельные риски.</p>\n<h2>Воспроизводимая проверка через curl</h2>\n<p><code>curl</code> не применяет browser policy и потому не может сам показать, что JavaScript увидит response. Он полезен для проверки фактических HTTP-заголовков и серверного статуса. Запускайте команды против тестового API, подставляя только тестовые значения <code>SESSION_COOKIE</code> и <code>CSRF_TOKEN</code>. В production-сессию их не копируйте.</p>\n<pre><code>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\"}'</code></pre>\n<p>В третьей проверке недостаточно увидеть <code>403</code>. Сверьте состояние профиля и серверный log: обработчик не должен вызвать <code>applyProfileChange</code>. Добавьте ещё два отрицательных запуска — с разрешённым origin, но без token, и с token от другой тестовой сессии. Для каждого заранее запишите ожидаемый статус и признак отсутствия изменения.</p>\n<h2>Матрица симптомов</h2>\n<table><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Вероятная граница</th><th scope=\"col\">Что собрать</th><th scope=\"col\">Следующее действие</th></tr></thead><tbody><tr><td>OPTIONS отклонён</td><td>CORS не разрешил method или header</td><td><code>Origin</code>, <code>Access-Control-Request-Method</code>, <code>Access-Control-Request-Headers</code>, статус</td><td>Сузить и согласовать фактический allow-list; CSRF middleware не менять</td></tr><tr><td>OPTIONS успешен, PATCH получает 403</td><td>Серверный CSRF или authorization</td><td>Причина отказа до mutation, наличие token, сессия и permission</td><td>Разделить token check и право на объект; не добавлять новый CORS origin</td></tr><tr><td>PATCH изменяет данные, но JavaScript видит CORS error</td><td>CORS response оформлен неправильно после actual request</td><td>Server status, response headers и факт изменения</td><td>Исправить response contract, сохранив CSRF и authorization</td></tr><tr><td>Всё работает без custom header</td><td>Возможно, endpoint принимает простой form-shaped запрос по cookie</td><td>POST/PUT-путь без preflight и без CSRF proof в тестовой сессии</td><td>Закрыть каждый state-changing путь серверной проверкой</td></tr><tr><td>Разрешён чужой поддомен</td><td>Слабое сравнение host или динамическое эхо origin</td><td>Полные origin со scheme и port, конфигурацию allow-list</td><td>Сравнивать нормализованный origin с фиксированными значениями</td></tr></tbody></table>\n<h2>Порядок проверки одного endpoint</h2>\n<ol><li>Выберите одну state-changing операцию и зафиксируйте цену ошибки: потеря видимости ответа, отклонённая запись или реально выполненное изменение.</li><li>Снимите точный request contract: source origin, URL, method, content type, credentials и заголовки. Слово «фронтенд» не заменяет эти значения.</li><li>Проверьте origin как scheme/host/port tuple. Отдельно протестируйте другой scheme, port и похожее имя домена.</li><li>Проверьте CORS response и preflight. Для динамического origin сверяйте также <code>Vary: Origin</code> и поведение кеша.</li><li>Проверьте CSRF proof до mutation: отсутствующий, неверный и принадлежащий другой сессии token должны завершаться отказом.</li><li>Проверьте authorization после CSRF: валидный token не даёт пользователю права менять чужой профиль.</li><li>Повторите happy path в реальном тестовом браузере, а не только через <code>curl</code>. Сопоставьте DevTools Network с серверным log.</li><li>Закрепите положительный и отрицательные сценарии интеграционным тестом. Не записывайте в fixture реальные cookie, token или персональные данные.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Схема относится к браузерному клиенту, который использует cookie или другую автоматически прикладываемую credential. Для bearer token, который JavaScript явно кладёт в заголовок и не получает из cookie, классическая CSRF-модель обычно отличается; это не отменяет проверки authentication, authorization и защиты от XSS. Webhook и server-to-server вызовам нужен свой контракт подписи, replay-защиты и прав.</p>\n<p>SameSite ограничивает отправку cookie, но его итог зависит от атрибутов cookie, контекста навигации, браузера и политики third-party cookies. Его разумно рассматривать как слой защиты, а не как повод удалить серверную проверку. Origin иногда отсутствует или имеет значение <code>null</code>; proxy может изменить наблюдаемую картину; redirect может привести к другому origin. Для этих случаев нужна явная политика отказа или отдельная проверка, а не молчаливое разрешение.</p>\n<p>GET не должен менять состояние. Если legacy endpoint нарушает это правило, его нельзя считать безопасным только из-за метода: OWASP рекомендует защитить такой ресурс от CSRF и планировать миграцию. XSS на доверенном origin также выходит за рамки CORS и может действовать изнутри приложения. Поэтому нужны отдельные меры для XSS, cookie policy, CSP, аудита и ограничения прав.</p>\n<p>Учебный код не моделирует конкретный framework, браузерный кеш, все правила cookies или сетевые proxy. Его результат — проверяемый порядок условий, а не доказательство безопасности production API. Доказательством служит повторяемый browser/integration тест вместе с server-side evidence на контролируемой тестовой сессии.</p>\n<h2>Критерий готовности</h2>\n<p>Один endpoint можно считать проверенным, если для него названы разрешённые origin и credentials-контракт, воспроизведён preflight либо объяснено его отсутствие, а server log показывает проверку CSRF до побочного эффекта и authorization после неё. Тестовая сессия должна успешно прочитать разрешённый response и изменить только свой профиль. Другой origin, пустой token, token другой сессии и попытка изменить чужой объект должны завершаться отказом без изменения состояния. Один заголовок CORS или один успешный ручной запрос этих доказательств не заменяет.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://fetch.spec.whatwg.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard</a> — нормативное описание CORS protocol, credentials mode, preflight, safelisted методов и обработки response. Спецификация задаёт browser/network semantics, но не выбирает бизнес-правила вашего API.</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>. Документ не является готовой allow-list-конфигурацией.</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> — synchronizer token, double-submit, custom headers, SameSite и запрет state-changing GET. Рекомендации нужно сопоставить с authentication-моделью и framework.</li></ul>"
}