8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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>"
|
||
}
|