CSRF: токены и SameSite-cookie

CSRF заставляет браузер жертвы сделать запрос, которого она не хотела.

CSRF (Cross-Site Request Forgery) — атака, при которой сторонний сайт инициирует запрос к вашему приложению от имени уже залогиненного пользователя, используя его автоматически отправляемые куки.

Как возникает

Корень проблемы — поведение браузера: к запросу на ваш домен он автоматически прикрепляет ваши куки, в том числе сессионную, независимо от того, какой сайт этот запрос инициировал. Если авторизация держится только на куке, чужая страница может отправить форму на ваш домен, и сервер примет её как легитимную.

Это поведение когда-то было задумано как удобство: благодаря ему вы остаётесь залогинены, переходя между страницами сайта, и вам не нужно вводить пароль на каждой. Но та же автоматичность означает, что кука прикладывается и тогда, когда запрос рождается на постороннем сайте. Браузер не различает «пользователь сам нажал кнопку на нашем сайте» и «скрытая форма на чужой странице выстрелила в наш домен» — для него это одинаковые исходящие запросы к одному адресу. Именно поэтому защиту нельзя строить на предположении, что раз кука пришла, значит, действие инициировал сам пользователь у нас на сайте.

// На вредоносном сайте: форма, которая сама отправляется на ваш домен
<form action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
// браузер жертвы приложит её куку bank.example -> перевод «от её имени»

Заметьте отличие от XSS: при CSRF атакующий не читает ответ и не крадёт данные напрямую — он лишь вынуждает совершить действие (перевод, смена пароля, удаление).

Разберём это на бытовом сценарии. Пользователь утром залогинился в интернет-банк и не вышел — вкладка осталась открытой, сессия в куке жива. В обед он по ссылке из письма или мессенджера открывает безобидную с виду страницу с котиками. На этой странице спрятана автоматически отправляемая форма, нацеленная на адрес перевода в его банке. Браузер, увидев запрос на домен банка, послушно прикладывает к нему сессионную куку — ведь он всегда так делает для этого домена. Банк получает корректно подписанный куками запрос и выполняет перевод. Пользователь не нажимал ни одной кнопки в банке и даже не открывал его — но деньги ушли. В этом и состоит коварство CSRF: атака паразитирует на доверии сервера к собственной куке.

Отсюда вытекает и главный признак уязвимого места: изменяющее действие, которое сервер выполняет, опираясь только на наличие сессионной куки и больше ни на что. Любая такая операция — перевод, смена email, удаление аккаунта, изменение прав — кандидат на CSRF, пока к ней не добавлено доказательство того, что запрос действительно исходит с вашей страницы, а не с чужой.

Защита 1: анти-CSRF-токен

Сервер генерирует случайный токен, кладёт его в форму/страницу и проверяет при изменяющем запросе. Сторонний сайт не может прочитать ваш токен (его защищает same-origin policy), поэтому подделанный запрос придёт без верного токена и будет отклонён.

// Сервер вставляет токен в форму
<input type="hidden" name="csrf_token" value="r4Nd0mPerSession">

// При POST сервер сверяет присланный токен с ожидаемым
if (request.csrf_token !== session.csrf_token) reject(); // нет/неверный -> отказ

Защита 2: SameSite-cookie

Атрибут SameSite говорит браузеру не прикреплять куку к запросам, инициированным с других сайтов. Это закрывает сам механизм CSRF на уровне браузера.

session_cookie:
  sameSite: "Lax"   # кука не уйдёт при межсайтовых POST/фоновых запросах
  httpOnly: true
  secure: true

Lax — разумный дефолт: кука едет при обычной навигации по ссылке, но не при межсайтовых POST. Strict строже, но иногда ломает переходы по внешним ссылкам. Это defense in depth — применяйте и токены, и SameSite.

Почему не положиться на один лишь SameSite, раз он закрывает механизм на уровне браузера? Во-первых, поведение зависит от браузера и его версии: старые клиенты могут не поддерживать атрибут, и тогда защита просто отсутствует. Во-вторых, режим Lax по дизайну пропускает куку при навигации верхнего уровня по ссылке — а это значит, что GET-запрос, меняющий состояние, всё ещё уязвим (ещё один довод за то, чтобы GET никогда ничего не менял). В-третьих, у вас могут быть сценарии с легитимными межсайтовыми запросами, ради которых приходится ослаблять SameSite. Анти-CSRF-токен таких компромиссов не требует и работает независимо от браузера, поэтому два рубежа вместе надёжнее любого по отдельности.

Современный и удобный вариант токена — паттерн «double submit cookie»: сервер кладёт случайное значение и в куку, и просит продублировать его в заголовке или поле формы, а затем сверяет, что они совпали. Чужой сайт не может прочитать вашу куку и потому не сумеет подставить совпадающее значение в заголовок. Этот подход не требует серверного хранения состояния и хорошо ложится на SPA и API, где классическую форму с hidden-полем рисовать неудобно.

Как работает под капотом: почему токен нельзя украсть

Same-origin policy запрещает скриптам с чужого домена читать содержимое ответа вашего домена. Поэтому злоумышленник может вслепую отправить запрос (форма уходит «не глядя»), но не может прочитать вашу страницу и достать оттуда CSRF-токен. Нет токена — нет валидного изменяющего запроса.

Здесь же кроется и фундаментальное различие между двумя атаками раздела. XSS пробивает same-origin policy изнутри: внедрённый скрипт исполняется на вашем же домене, поэтому может и читать страницу, и красть токены. CSRF, наоборот, действует строго снаружи и упирается в ту самую границу источников — он способен лишь отправить запрос, но не увидеть ответ. Из этого следует неприятный, но важный вывод: при наличии XSS защита от CSRF бесполезна, ведь внедрённый скрипт спокойно прочитает токен прямо со страницы. Поэтому два предыдущих урока и этот связаны: сначала закрывают XSS, и только потом анти-CSRF-меры обретают смысл.

[evil.com] --отправляет POST--> [bank.example]  (куку браузер приложит)
[evil.com] --НЕ может прочитать ответ bank.example-- (same-origin policy)
            -> значит, не знает csrf_token -> сервер отклоняет

Частые ошибки

  • Защищать только GET-проверкой. Изменяющие действия должны идти POST/PUT/DELETE и требовать токен; GET не должен менять состояние.
  • Один токен на всё навсегда. Привязывайте токен к сессии и обновляйте.
  • Полагаться только на SameSite. Поддержка и режимы различаются — комбинируйте с токеном.
  • Менять состояние по GET. Ссылку-«действие» вида /delete?id=5 легко вызвать со стороны; такие действия должны быть POST/PUT/DELETE с токеном.
  • Защищать только HTML-формы, забыв про API. JSON-эндпоинты, на которые ходит фронтенд, тоже подделываются и тоже нуждаются в проверке.

Итоги

  • CSRF использует автоматическую отправку кук: чужой сайт инициирует действие от имени жертвы.
  • Анти-CSRF-токен непредсказуем для атакующего и проверяется на изменяющих запросах.
  • SameSite-cookie не даёт куке уходить при межсайтовых запросах; применяйте оба рубежа.
Проверьте себя
1. За счёт чего вообще возможна CSRF-атака?
AИз-за слабого шифрования
BБраузер автоматически прикрепляет куки пользователя к запросу на ваш домен, кто бы его ни инициировал
CИз-за SQL-инъекции
DИз-за отсутствия HTTPS
2. Почему анти-CSRF-токен защищает запрос?
AОн шифрует тело запроса
BСторонний сайт не может прочитать ответ вашего домена (same-origin policy) и не знает токен, поэтому подделанный запрос приходит без него
CОн ускоряет обработку формы
DОн заменяет пароль пользователя
3. Что делает атрибут SameSite у сессионной куки?
AШифрует значение куки
BУказывает браузеру не отправлять куку при запросах, инициированных с других сайтов
CПродлевает срок жизни куки
DРазрешает доступ к куке из JavaScript