Files

8 lines
27 KiB
JSON
Raw Permalink 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": 61,
"slug": "editorial-2026-04-field-modern-web-security",
"title": "Почему CORS не защищает POST: как проверить границы веб-безопасности",
"excerpt": "Чужой сайт может отправить запрос с cookie даже после настройки CORS. Разбираем механизм на тестовом endpoint, проверяем отрицательный путь и фиксируем границы вывода.",
"contentHtml": "<p>Симптом выглядит обнадёживающе: API отвечает только для нужного <code>Origin</code>, а в DevTools чужой сайт получает ошибку CORS. Команда закрывает задачу. Но тот же endpoint всё ещё может принять cross-site POST с cookie и изменить состояние. Браузер способен отправить запрос, даже если JavaScript на странице-источнике не прочитает ответ. Если действие меняет email, адрес доставки или лимит, ошибка CORS не отменяет запись.</p>\n<p>Цена ошибки — неверный диагноз. CORS (Cross-Origin Resource Sharing) регулирует, какой ответ браузер отдаёт скрипту другого origin. CSRF (Cross-Site Request Forgery) защищает state-changing действие от запроса, который пользователь не намеревался выполнять. Это разные границы. Ниже — тестовый сценарий для локального или специально разрешённого стенда: он показывает, как отличить отказ в чтении ответа от отказа операции и не выдать один сигнал за доказательство всей защиты.</p>\n<h2>Сначала назовите действие, а не заголовок</h2>\n<p>Представим приложение <code>https://app.example.test</code> и страницу злоумышленника <code>https://attacker.example.test</code>. Пользователь уже вошёл в приложение. Браузер хранит session cookie и по правилам cookie может приложить её к запросу. На странице-источнике размещена форма, которая отправляет URL-encoded POST. Такой запрос похож на обычную HTML-форму и не обязан начинаться с CORS preflight.</p>\n<pre><code>&lt;form action='https://app.example.test/profile/email' method='POST'&gt;\n &lt;input name='email' value='attacker@example.test'&gt;\n&lt;/form&gt;\n&lt;script&gt;document.forms[0].submit()&lt;/script&gt;\n\n// Учебный пример: не открывать его против чужого сервиса.\n// Цель — проверить, что сервер делает до изменения состояния.</code></pre>\n<p>В этой форме нет JavaScript-операции чтения ответа. Поэтому запрет <code>Access-Control-Allow-Origin</code> для <code>attacker.example.test</code> не равен запрету самой отправки. Браузер может показать странице-источнику ошибку или перенаправление, но сервер уже мог получить запрос. Проверять нужно не консоль браузера, а серверный результат: статус, вызов обработчика и факт записи.</p>\n<h2>Что именно делает CORS</h2>\n<p>CORS — протокол согласования между браузером и сервером. В ответе сервер сообщает, какому origin можно предоставить response body, какие методы и заголовки разрешены, а при необходимости — можно ли раскрывать ответ с credentials. Для нестандартного запроса браузер обычно посылает предварительный <code>OPTIONS</code>, а затем отправляет основной запрос только после подходящего ответа.</p>\n<p>Это полезная граница для API, которому действительно нужно cross-origin чтение. Но CORS не предназначен для универсальной остановки запросов, похожих на отправку формы. Спецификация Fetch прямо рассматривает такие запросы как существующую возможность платформы: сервер должен сам защищать state-changing операции от CSRF. Поэтому allowlist отвечает на вопрос «кто может прочитать ответ из браузерного JavaScript?», а не «кто вправе изменить данные?».</p>\n<table><caption>Какой сигнал что доказывает</caption><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Поддерживаемый вывод</th><th scope='col'>Чего оно не доказывает</th><th scope='col'>Следующая проверка</th></tr></thead><tbody><tr><td>В консоли отображается CORS error</td><td>Скрипт не получил доступ к ответу по этому запросу</td><td>Что сервер не получил запрос или не изменил данные</td><td>Проверить серверный журнал и тестовое хранилище</td></tr><tr><td><code>OPTIONS</code> получил отказ</td><td>Конкретный preflight не разрешил последующий сложный запрос</td><td>Что простой POST или HTML-форма остановлены</td><td>Отдельно проверить form-shaped POST</td></tr><tr><td>POST вернул <code>403</code> без токена</td><td>Эта ветка endpoint отклоняет запрос без доказательства намерения</td><td>Что middleware подключён ко всем изменяющим маршрутам</td><td>Составить список state-changing маршрутов</td></tr><tr><td>Cookie имеет <code>SameSite=Lax</code></td><td>Браузер применяет ограничение cookie в части cross-site контекстов</td><td>Что subdomain, GET и старые клиенты не создают риск</td><td>Проверить site/origin и все изменяющие методы</td></tr><tr><td>Fetch с JSON не прошёл preflight</td><td>Браузер не отправил этот основной запрос после отказа preflight</td><td>Что другой клиент не отправит URL-encoded форму</td><td>Проверить серверную защиту независимо от клиента</td></tr></tbody></table>\n<p>Таблица нужна как стоп-сигнал для ревью. Нельзя переносить вывод из первой колонки на соседнюю строку. Особенно опасна подмена «ответ не прочитан» на «операция не выполнена»: это разные наблюдения и разные журналы.</p>\n<h2>Где проходит серверная граница</h2>\n<p>Защита должна сработать до побочного эффекта. На сервере сначала проверяют метод, сессию и контекст запроса, затем CSRF-токен или иной выбранный механизм, потом права и данные, и только после этого вызывают сохранение. Порядок зависит от фреймворка, но отрицательная ветка должна завершиться до функции, которая меняет состояние.</p>\n<pre><code>function updateEmail(request) {\n if (request.method !== 'POST') return reject(405);\n if (!request.session?.userId) return reject(401);\n if (!sameOrigin(request.headers.origin)) return reject(403);\n if (!validCsrfToken(request.session, request.body.csrfToken)) {\n return reject(403);\n }\n if (!validEmail(request.body.email)) return reject(400);\n\n return saveEmail(request.session.userId, request.body.email);\n}\n\n// Псевдокод: storage, токены и ответы зависят от фреймворка.\n// В тесте нужно проверить, что saveEmail не был вызван.</code></pre>\n<p>Код показывает контракт, а не готовый middleware. <code>sameOrigin</code> должен корректно обработать отсутствие заголовка и доверенные схемы. Проверка токена должна использовать серверное состояние либо безопасно связанный double-submit-механизм. Авторизация отвечает на другой вопрос: имеет ли пользователь право менять email. CSRF-токен не заменяет авторизацию, а авторизация не доказывает намерение запроса.</p>\n<h2>Воспроизводимая проверка в тестовом стенде</h2>\n<p>Поднимите тестовый обработчик с записью в памяти, журналом вызовов и двумя ветками: позитивной и отрицательной. Значение <code>TARGET</code> ниже замените адресом своего локального HTTPS-стенда или изолированного окружения. Cookie <code>TEST_ONLY_SESSION</code> должна быть фиктивной, а обработчик — не связан с реальными данными.</p>\n<pre><code>TARGET='https://app.example.test'\n\n# URL-encoded запрос с чужим Origin и тестовой cookie.\n# Не запускать против production или чужого сервиса.\ncurl --silent --show-error --include --request POST \\\n \"$TARGET/profile/email\" \\\n --header 'Origin: https://attacker.example.test' \\\n --header 'Content-Type: application/x-www-form-urlencoded' \\\n --cookie 'session=TEST_ONLY_SESSION' \\\n --data 'email=attacker%40example.test'\n\n# Позитивная ветка: тот же стенд, корректный тестовый токен.\ncurl --silent --show-error --include --request POST \\\n \"$TARGET/profile/email\" \\\n --header 'Origin: https://app.example.test' \\\n --header 'Content-Type: application/x-www-form-urlencoded' \\\n --cookie 'session=TEST_ONLY_SESSION' \\\n --data 'email=user%40example.test&amp;csrfToken=TEST_ONLY_TOKEN'</code></pre>\n<p>Ожидаемый отрицательный результат — сервер отклоняет запрос до <code>saveEmail</code>, а тестовая запись остаётся прежней. Положительный запрос с корректным токеном проходит согласно контракту стенда. Сохраните статус, идентификатор запроса, причину отказа и состояние записи до и после. Если сервер вернул <code>403</code>, но запись изменилась асинхронно, проверка не пройдена: один статус не описывает весь побочный эффект.</p>\n<p>Команда с чужим <code>Origin</code> проверяет ветку origin, но не моделирует все варианты браузера. Для form-shaped POST важнее убрать токен и проверить факт записи. Для JSON-клиента добавьте preflight и убедитесь, что CORS не позволяет незапланированному origin выполнить основной запрос с credentials. Каждый сценарий должен иметь собственный идентификатор и ожидаемое состояние.</p>\n<figure><img src='/assets/editorial/2026/modern-web-security-2026-evidence-review-loop.svg' alt='Цикл проверки веб-безопасности: назвать действие, связать запрос с серверным контролем, проверить отрицательный путь, записать границу evidence и остаточный риск' loading='lazy' /><figcaption>Проверка движется от действия к серверной записи. Ошибка чтения ответа не закрывает цикл, пока не проверено, что изменилось на сервере.</figcaption></figure>\n<h2>Какие защиты сочетать</h2>\n<p>Для cookie-аутентифицированного приложения базовый выбор — встроенная защита фреймворка от CSRF либо серверный synchronizer token. Сервер выдаёт непредсказуемый токен, клиент возвращает его в форме или заголовке, а сервер сравнивает его с ожидаемым значением до записи. Для stateless-систем возможен signed double-submit cookie, но подпись должна быть связана с сессией; простое совпадение двух значений без такой связи не даёт того же свойства.</p>\n<p>Проверка <code>Origin</code> или, если его нет, аккуратная проверка <code>Referer</code> — дополнительный барьер. Fetch Metadata, например <code>Sec-Fetch-Site</code>, тоже может помочь отсечь cross-site контекст, если продукт контролирует поддержку браузеров и предусмотрел fallback. Эти механизмы не освобождают от проверки endpoint-ов: на старом маршруте может отсутствовать middleware, а клиентская библиотека — иметь отдельную ветку.</p>\n<p><code>SameSite=Strict</code> уменьшает отправку cookie в cross-site контексте, но может ломать переходы по внешним ссылкам. <code>Lax</code> оставляет более мягкую модель и не должен становиться единственным доказательством защиты. SameSite описывает site, а не origin: два subdomain одного registrable domain могут считаться same-site. Если среди subdomain есть пользовательский контент, legacy-приложение или чужая управляемая зона, остаточный риск нужно оценивать отдельно.</p>\n<p>В CORS-конфигурации задавайте явный список доверенных origin. Не отражайте произвольный входной <code>Origin</code> в <code>Access-Control-Allow-Origin</code>, если не проверили его по allowlist. Для credentialed cross-origin запросов wildcard <code>*</code> не подходит. Добавляйте <code>Vary: Origin</code>, когда ответ зависит от этого заголовка и проходит через кэш. Эти меры ограничивают чтение и отправку некоторых запросов, но не заменяют серверную CSRF-проверку для form-shaped POST.</p>\n<h2>Отрицательные сценарии важнее зелёного заголовка</h2>\n<p>Минимальный набор тестов должен ломать каждый предполагаемый барьер по отдельности. Уберите токен, подмените origin, удалите cookie, используйте неподдержанный метод, отправьте запрос через форму и повторите запрос с корректными данными. Для каждого случая зафиксируйте проверку, которая должна остановить путь. Если разные причины возвращают один <code>403</code>, это допустимо для внешнего ответа, но внутренний лог должен различать ветки без записи секретов.</p>\n<table><caption>Минимальная матрица тестов endpoint-а</caption><thead><tr><th scope='col'>Ветка</th><th scope='col'>Изменение входа</th><th scope='col'>Ожидаемое действие</th><th scope='col'>Факт, который нужно сохранить</th></tr></thead><tbody><tr><td>Позитивная</td><td>Действующая тестовая сессия и корректный токен</td><td>Вызвать сохранение один раз</td><td>Новая запись и correlation id</td></tr><tr><td>CSRF token missing</td><td>Убрать токен, оставить cookie</td><td>Отклонить до сохранения</td><td>403 и неизменённая запись</td></tr><tr><td>Origin foreign</td><td>Передать чужой origin</td><td>Отклонить по правилу стенда</td><td>Решение origin-check и запись</td></tr><tr><td>Unauthenticated</td><td>Убрать сессию</td><td>Отклонить до операции</td><td>401/403 и отсутствие эффекта</td></tr><tr><td>Form-shaped POST</td><td>URL-encoded body без preflight</td><td>Применить тот же CSRF-контроль</td><td>Результат этой ветки, а не OPTIONS</td></tr><tr><td>GET state change</td><td>Вызвать старый GET-маршрут</td><td>Не менять состояние</td><td>Маршрут найден или отмечен как риск</td></tr></tbody></table>\n<p>Проверка «в браузере показалась CORS error» — только подсказка. Она не заменяет наблюдение за обработчиком и хранилищем. Если нет server-side evidence, пишите: «скрипт не прочитал ответ в этом сценарии». Нельзя добавлять к этому «данные не изменились» без отдельного сигнала.</p>\n<h2>Как оформить ограниченный результат</h2>\n<p>Запись проверки содержит пять полей: <code>threat</code>, <code>endpoint</code>, <code>control</code>, <code>observed</code> и <code>not-proven</code>. Например: threat — «чужая форма пытается изменить email»; endpoint — <code>POST /profile/email</code>; control — «сервер отклоняет запрос без токена до <code>saveEmail</code>»; observed — «тест без токена вернул 403, запись не изменилась»; not-proven — «другие маршруты, браузеры и production proxy не проверены».</p>\n<p>Такая запись связывает наблюдение с конкретной операцией. Она не утверждает, что весь API защищён, что любой браузер ведёт себя одинаково или что проблема устранена в production. Если проверка проходила только на фиктивной функции, результат относится к этой функции и входу. Для стенда добавьте версию приложения, cookie policy и идентификатор сборки.</p>\n<h2>Ограничения применимости</h2>\n<p>Описание предполагает cookie-аутентификацию и state-changing endpoint. Для API с bearer-токеном в заголовке модель CSRF отличается: браузер не прикладывает такой заголовок автоматически, но XSS, утечка токена, CORS и неверная клиентская маршрутизация остаются отдельными угрозами. Для OAuth, embedded webview, native-клиента, service worker и межсайтовых редиректов нужны свои сценарии. Не переносите этот вывод на них без новой проверки.</p>\n<p>Поведение зависит от браузера, схемы, доменов, атрибутов cookie, reverse proxy и серверного фреймворка. <code>SameSite</code> не является абсолютной границей: он различает site, а не origin, и не исправляет state-changing GET. Preflight проверяет только запросы, которые квалифицируются для preflight. Кэш может отдать CORS-ответ без корректного варианта <code>Origin</code>, если конфигурация не учитывает <code>Vary</code>.</p>\n<p>Учебные curl-команды не доказывают поведение реального браузера. Они помогают воспроизвести HTTP-вход и проверить серверный контракт. Для браузерного вывода добавьте автоматизированный тест в поддерживаемых браузерах и проверьте фактические cookie, response headers, preflight, запись и логи. Если не проверены другие state-changing endpoint-ы, честный результат — частичный, а не общий verdict.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Назовите asset и операцию.</strong> Укажите, какие данные меняются и каким HTTP-маршрутом.</li><li><strong>Зафиксируйте модель входа.</strong> Запишите cookie, origin, метод, content type, токен и ожидаемый побочный эффект.</li><li><strong>Разделите CORS и CSRF.</strong> Для CORS проверяйте доступ к response body, для CSRF — отказ до изменения состояния.</li><li><strong>Проверьте form-shaped POST.</strong> Не ограничивайтесь <code>OPTIONS</code> и JSON-клиентом.</li><li><strong>Запустите позитивный и отрицательный тест.</strong> Сравните статус, вызов обработчика и запись.</li><li><strong>Проверьте покрытие.</strong> Найдите все POST, PUT, PATCH, DELETE и старые GET, которые меняют состояние.</li><li><strong>Добавьте глубинные барьеры.</strong> Оцените token, origin, Fetch Metadata, SameSite и CORS по отдельности.</li><li><strong>Запишите границы.</strong> Укажите стенд, браузеры, версии, proxy, непроверенные маршруты и остаточный риск.</li><li><strong>Сформулируйте вывод по evidence.</strong> Если доказана только ветка чтения ответа, так и напишите; изменение системы требует отдельного решения.</li></ol>\n<h2>Критерий готовности</h2>\n<p>Проверку можно считать завершённой для конкретного endpoint-а, когда известны его state-changing операции, позитивная ветка проходит с тестовой сессией и корректным токеном, а form-shaped запрос без токена не вызывает запись. Для каждой отрицательной ветки виден контроль и сохранён факт результата. CORS-конфигурация содержит явные доверенные origin и согласованные credential rules. Остальные маршруты и средовые ограничения перечислены.</p>\n<p>Это узкий критерий. Он не обещает отсутствие CSRF, не выдаёт сертификат безопасности и не превращает CORS error в доказательство. Он даёт следующему инженеру воспроизводимую последовательность: какой запрос повторить, где посмотреть эффект и какое утверждение пока запрещено. Если хотя бы один state-changing маршрут не прошёл такой путь, общий вывод нужно остановить.</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: CORS protocol</a> — описывает CORS, preflight, credentials и границу между отправкой запроса и доступом к ответу; стандарт не подтверждает конфигурацию конкретного API.</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> — рекомендует встроенную защиту фреймворка, серверную проверку токена, origin/Referer и рассматривает ограничения SameSite; рекомендация не является аудитом приложения.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF' target='_blank' rel='noopener noreferrer'>MDN: Cross-site request forgery (CSRF)</a> — объясняет form-shaped simple requests, preflight, Fetch Metadata и SameSite; это справочная документация о веб-платформе, а не гарантия поведения каждого клиента.</li></ul>"
}