{ "index": 61, "slug": "editorial-2026-04-field-modern-web-security", "title": "Почему CORS не защищает POST: как проверить границы веб-безопасности", "excerpt": "Чужой сайт может отправить запрос с cookie даже после настройки CORS. Разбираем механизм на тестовом endpoint, проверяем отрицательный путь и фиксируем границы вывода.", "contentHtml": "
Симптом выглядит обнадёживающе: API отвечает только для нужного Origin, а в DevTools чужой сайт получает ошибку CORS. Команда закрывает задачу. Но тот же endpoint всё ещё может принять cross-site POST с cookie и изменить состояние. Браузер способен отправить запрос, даже если JavaScript на странице-источнике не прочитает ответ. Если действие меняет email, адрес доставки или лимит, ошибка CORS не отменяет запись.
Цена ошибки — неверный диагноз. CORS (Cross-Origin Resource Sharing) регулирует, какой ответ браузер отдаёт скрипту другого origin. CSRF (Cross-Site Request Forgery) защищает state-changing действие от запроса, который пользователь не намеревался выполнять. Это разные границы. Ниже — тестовый сценарий для локального или специально разрешённого стенда: он показывает, как отличить отказ в чтении ответа от отказа операции и не выдать один сигнал за доказательство всей защиты.
\nПредставим приложение https://app.example.test и страницу злоумышленника https://attacker.example.test. Пользователь уже вошёл в приложение. Браузер хранит session cookie и по правилам cookie может приложить её к запросу. На странице-источнике размещена форма, которая отправляет URL-encoded POST. Такой запрос похож на обычную HTML-форму и не обязан начинаться с CORS preflight.
<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// Цель — проверить, что сервер делает до изменения состояния.\nВ этой форме нет JavaScript-операции чтения ответа. Поэтому запрет Access-Control-Allow-Origin для attacker.example.test не равен запрету самой отправки. Браузер может показать странице-источнику ошибку или перенаправление, но сервер уже мог получить запрос. Проверять нужно не консоль браузера, а серверный результат: статус, вызов обработчика и факт записи.
CORS — протокол согласования между браузером и сервером. В ответе сервер сообщает, какому origin можно предоставить response body, какие методы и заголовки разрешены, а при необходимости — можно ли раскрывать ответ с credentials. Для нестандартного запроса браузер обычно посылает предварительный OPTIONS, а затем отправляет основной запрос только после подходящего ответа.
Это полезная граница для API, которому действительно нужно cross-origin чтение. Но CORS не предназначен для универсальной остановки запросов, похожих на отправку формы. Спецификация Fetch прямо рассматривает такие запросы как существующую возможность платформы: сервер должен сам защищать state-changing операции от CSRF. Поэтому allowlist отвечает на вопрос «кто может прочитать ответ из браузерного JavaScript?», а не «кто вправе изменить данные?».
\n| Наблюдение | Поддерживаемый вывод | Чего оно не доказывает | Следующая проверка |
|---|---|---|---|
| В консоли отображается CORS error | Скрипт не получил доступ к ответу по этому запросу | Что сервер не получил запрос или не изменил данные | Проверить серверный журнал и тестовое хранилище |
OPTIONS получил отказ | Конкретный preflight не разрешил последующий сложный запрос | Что простой POST или HTML-форма остановлены | Отдельно проверить form-shaped POST |
POST вернул 403 без токена | Эта ветка endpoint отклоняет запрос без доказательства намерения | Что middleware подключён ко всем изменяющим маршрутам | Составить список state-changing маршрутов |
Cookie имеет SameSite=Lax | Браузер применяет ограничение cookie в части cross-site контекстов | Что subdomain, GET и старые клиенты не создают риск | Проверить site/origin и все изменяющие методы |
| Fetch с JSON не прошёл preflight | Браузер не отправил этот основной запрос после отказа preflight | Что другой клиент не отправит URL-encoded форму | Проверить серверную защиту независимо от клиента |
Таблица нужна как стоп-сигнал для ревью. Нельзя переносить вывод из первой колонки на соседнюю строку. Особенно опасна подмена «ответ не прочитан» на «операция не выполнена»: это разные наблюдения и разные журналы.
\nЗащита должна сработать до побочного эффекта. На сервере сначала проверяют метод, сессию и контекст запроса, затем CSRF-токен или иной выбранный механизм, потом права и данные, и только после этого вызывают сохранение. Порядок зависит от фреймворка, но отрицательная ветка должна завершиться до функции, которая меняет состояние.
\nfunction 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 не был вызван.\nКод показывает контракт, а не готовый middleware. sameOrigin должен корректно обработать отсутствие заголовка и доверенные схемы. Проверка токена должна использовать серверное состояние либо безопасно связанный double-submit-механизм. Авторизация отвечает на другой вопрос: имеет ли пользователь право менять email. CSRF-токен не заменяет авторизацию, а авторизация не доказывает намерение запроса.
Поднимите тестовый обработчик с записью в памяти, журналом вызовов и двумя ветками: позитивной и отрицательной. Значение TARGET ниже замените адресом своего локального HTTPS-стенда или изолированного окружения. Cookie TEST_ONLY_SESSION должна быть фиктивной, а обработчик — не связан с реальными данными.
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'\nОжидаемый отрицательный результат — сервер отклоняет запрос до saveEmail, а тестовая запись остаётся прежней. Положительный запрос с корректным токеном проходит согласно контракту стенда. Сохраните статус, идентификатор запроса, причину отказа и состояние записи до и после. Если сервер вернул 403, но запись изменилась асинхронно, проверка не пройдена: один статус не описывает весь побочный эффект.
Команда с чужим Origin проверяет ветку origin, но не моделирует все варианты браузера. Для form-shaped POST важнее убрать токен и проверить факт записи. Для JSON-клиента добавьте preflight и убедитесь, что CORS не позволяет незапланированному origin выполнить основной запрос с credentials. Каждый сценарий должен иметь собственный идентификатор и ожидаемое состояние.
Для cookie-аутентифицированного приложения базовый выбор — встроенная защита фреймворка от CSRF либо серверный synchronizer token. Сервер выдаёт непредсказуемый токен, клиент возвращает его в форме или заголовке, а сервер сравнивает его с ожидаемым значением до записи. Для stateless-систем возможен signed double-submit cookie, но подпись должна быть связана с сессией; простое совпадение двух значений без такой связи не даёт того же свойства.
\nПроверка Origin или, если его нет, аккуратная проверка Referer — дополнительный барьер. Fetch Metadata, например Sec-Fetch-Site, тоже может помочь отсечь cross-site контекст, если продукт контролирует поддержку браузеров и предусмотрел fallback. Эти механизмы не освобождают от проверки endpoint-ов: на старом маршруте может отсутствовать middleware, а клиентская библиотека — иметь отдельную ветку.
SameSite=Strict уменьшает отправку cookie в cross-site контексте, но может ломать переходы по внешним ссылкам. Lax оставляет более мягкую модель и не должен становиться единственным доказательством защиты. SameSite описывает site, а не origin: два subdomain одного registrable domain могут считаться same-site. Если среди subdomain есть пользовательский контент, legacy-приложение или чужая управляемая зона, остаточный риск нужно оценивать отдельно.
В CORS-конфигурации задавайте явный список доверенных origin. Не отражайте произвольный входной Origin в Access-Control-Allow-Origin, если не проверили его по allowlist. Для credentialed cross-origin запросов wildcard * не подходит. Добавляйте Vary: Origin, когда ответ зависит от этого заголовка и проходит через кэш. Эти меры ограничивают чтение и отправку некоторых запросов, но не заменяют серверную CSRF-проверку для form-shaped POST.
Минимальный набор тестов должен ломать каждый предполагаемый барьер по отдельности. Уберите токен, подмените origin, удалите cookie, используйте неподдержанный метод, отправьте запрос через форму и повторите запрос с корректными данными. Для каждого случая зафиксируйте проверку, которая должна остановить путь. Если разные причины возвращают один 403, это допустимо для внешнего ответа, но внутренний лог должен различать ветки без записи секретов.
| Ветка | Изменение входа | Ожидаемое действие | Факт, который нужно сохранить |
|---|---|---|---|
| Позитивная | Действующая тестовая сессия и корректный токен | Вызвать сохранение один раз | Новая запись и correlation id |
| CSRF token missing | Убрать токен, оставить cookie | Отклонить до сохранения | 403 и неизменённая запись |
| Origin foreign | Передать чужой origin | Отклонить по правилу стенда | Решение origin-check и запись |
| Unauthenticated | Убрать сессию | Отклонить до операции | 401/403 и отсутствие эффекта |
| Form-shaped POST | URL-encoded body без preflight | Применить тот же CSRF-контроль | Результат этой ветки, а не OPTIONS |
| GET state change | Вызвать старый GET-маршрут | Не менять состояние | Маршрут найден или отмечен как риск |
Проверка «в браузере показалась CORS error» — только подсказка. Она не заменяет наблюдение за обработчиком и хранилищем. Если нет server-side evidence, пишите: «скрипт не прочитал ответ в этом сценарии». Нельзя добавлять к этому «данные не изменились» без отдельного сигнала.
\nЗапись проверки содержит пять полей: threat, endpoint, control, observed и not-proven. Например: threat — «чужая форма пытается изменить email»; endpoint — POST /profile/email; control — «сервер отклоняет запрос без токена до saveEmail»; observed — «тест без токена вернул 403, запись не изменилась»; not-proven — «другие маршруты, браузеры и production proxy не проверены».
Такая запись связывает наблюдение с конкретной операцией. Она не утверждает, что весь API защищён, что любой браузер ведёт себя одинаково или что проблема устранена в production. Если проверка проходила только на фиктивной функции, результат относится к этой функции и входу. Для стенда добавьте версию приложения, cookie policy и идентификатор сборки.
\nОписание предполагает cookie-аутентификацию и state-changing endpoint. Для API с bearer-токеном в заголовке модель CSRF отличается: браузер не прикладывает такой заголовок автоматически, но XSS, утечка токена, CORS и неверная клиентская маршрутизация остаются отдельными угрозами. Для OAuth, embedded webview, native-клиента, service worker и межсайтовых редиректов нужны свои сценарии. Не переносите этот вывод на них без новой проверки.
\nПоведение зависит от браузера, схемы, доменов, атрибутов cookie, reverse proxy и серверного фреймворка. SameSite не является абсолютной границей: он различает site, а не origin, и не исправляет state-changing GET. Preflight проверяет только запросы, которые квалифицируются для preflight. Кэш может отдать CORS-ответ без корректного варианта Origin, если конфигурация не учитывает Vary.
Учебные curl-команды не доказывают поведение реального браузера. Они помогают воспроизвести HTTP-вход и проверить серверный контракт. Для браузерного вывода добавьте автоматизированный тест в поддерживаемых браузерах и проверьте фактические cookie, response headers, preflight, запись и логи. Если не проверены другие state-changing endpoint-ы, честный результат — частичный, а не общий verdict.
\nOPTIONS и JSON-клиентом.Проверку можно считать завершённой для конкретного endpoint-а, когда известны его state-changing операции, позитивная ветка проходит с тестовой сессией и корректным токеном, а form-shaped запрос без токена не вызывает запись. Для каждой отрицательной ветки виден контроль и сохранён факт результата. CORS-конфигурация содержит явные доверенные origin и согласованные credential rules. Остальные маршруты и средовые ограничения перечислены.
\nЭто узкий критерий. Он не обещает отсутствие CSRF, не выдаёт сертификат безопасности и не превращает CORS error в доказательство. Он даёт следующему инженеру воспроизводимую последовательность: какой запрос повторить, где посмотреть эффект и какое утверждение пока запрещено. Если хотя бы один state-changing маршрут не прошёл такой путь, общий вывод нужно остановить.
\n