8 lines
27 KiB
JSON
8 lines
27 KiB
JSON
{
|
||
"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><form action='https://app.example.test/profile/email' method='POST'>\n <input name='email' value='attacker@example.test'>\n</form>\n<script>document.forms[0].submit()</script>\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&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>"
|
||
}
|