Перейти к содержимому
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

Что такое CSRF и как помогают cookie с SameSite

Cross-site request forgery использует cookie вашего браузера против вас. Защиты от сильной к слабой и проверка SameSite в лаборатории на Flask.

Средний10 мин чтенияАвтор: NotHoly

Cross-site request forgery (CSRF) даёт странице одного сайта заставить ваш браузер отправить запрос, меняющий состояние, на другой сайт, где вы авторизованы. Браузер сам прикрепляет cookie сессии, поэтому тот сайт считает запрос вашим. Защиты такие: секрет на сессию, который злоумышленник не может прочитать, атрибут cookie SameSite, проверка Origin и правило, что GET-запросы никогда не меняют состояние. Статья разбирает каждую из них, а затем запускает небольшую лабораторию на Flask, где движение cookie видно своими глазами.

Что такое CSRF

Веб-сессия обычно живёт в cookie. После входа на bank.example браузер сохраняет cookie для этого хоста и отправляет её с каждым запросом к нему. Сервер читает cookie, находит вашу сессию и выполняет действие.

Браузер не проверяет, откуда пришёл запрос. Если на странице evil.example есть форма, отправляющая POST на https://bank.example/transfer, браузер отправит этот POST и прикрепит к нему cookie bank.example. Банк видит действительную сессию и переводит деньги. Злоумышленник ни разу не видит вашу cookie и не читает ответ. Для атаки нужно только, чтобы сервер выполнил действие.

CWE-352 описывает эту слабость так: приложение выполняет действие, меняющее состояние, от имени пользователя, не проверяя, что пользователь сам решил отправить запрос. Исправление в том, чтобы сервер умел отличать запрос пользователя от запроса, подделанного сторонней страницей.

Почему это важно

Этому риску подвержено любое приложение, которое хранит сессии в cookie и меняет данные по запросу. Опаснее всего действия, меняющие учётные данные или настройки: смена пароля, смена адреса email, новый API-ключ, новый способ оплаты, правило пересылки. Поддельная смена email часто становится первым шагом к захвату аккаунта, потому что затем злоумышленник может сбросить пароль через новый адрес.

Сессии в cookie используются по умолчанию во многих фреймворках, и браузер отправляет cookie независимо от того, подумал об этом разработчик или нет. Поэтому CSRF до сих пор попадает в списки OWASP и CWE.

Как проходит атака, шаг за шагом

  1. Жертва входит на bank.example. Браузер сохраняет cookie сессии для этого хоста.
  2. Жертва, не выходя из аккаунта, открывает в том же браузере другую страницу. Страница принадлежит злоумышленнику.
  3. На странице есть форма, отправляющая POST на https://bank.example/transfer со значениями, которые выбрал злоумышленник. Она может отправиться при загрузке, так что жертве даже не нужно ничего нажимать.
  4. Браузер отправляет POST и включает в него cookie bank.example.
  5. Сервер проверяет cookie, видит авторизованного пользователя и выполняет перевод.

Обратите внимание, чего злоумышленнику не понадобилось. Ему не пришлось красть cookie, подбирать пароль или читать хотя бы один байт ответа банка. Всю работу сделал браузер.

Почему GET не должен менять состояние

GET-запрос можно вызвать без всякой формы. Тег <img src="https://bank.example/delete?id=7"> заставляет браузер отправить GET-запрос, как только страница отрисуется. Предзагрузка, превью ссылок и краулеры тоже могут отправлять GET-запросы. Если GET-маршрут меняет данные, для CSRF форма вообще не нужна.

Правило простое: GET и HEAD читают данные и никогда их не меняют. Изменения идут через POST, PUT, PATCH или DELETE. Само по себе это правило защитой не считается, потому что POST всё равно можно подделать, но все остальные защиты работают лучше, когда оно соблюдается.

Защиты от сильной к слабой

1. Синхронизирующие токены (synchronizer token)

Сервер создаёт случайный токен для каждой сессии, вставляет его в каждую форму как скрытое поле и проверяет при каждом запросе, меняющем состояние. Запрос без правильного токена отклоняется.

Токен работает, потому что страница злоумышленника не может его прочитать. Same-origin policy не даёт evil.example читать HTML, который вернул bank.example, поэтому злоумышленник не может скопировать скрытое поле в поддельную форму. Поддельный запрос приходит с cookie, но без токена.

Токен должен быть непредсказуемым (используйте криптографический генератор случайных чисел), привязанным к сессии пользователя и проверяться при каждом небезопасном методе, включая эндпоинты, которые вызывает ваш JavaScript. Большинство фреймворков поставляется с такой защитой. По этой причине OWASP cheat sheet ставит защиты на основе токенов на первое место.

Double-submit cookie — более слабый вариант. Токен кладут и в cookie, и в поле запроса, а сервер их сравнивает. Злоумышленник, который может устанавливать cookie на вашем домене, например с соседнего поддомена, способен эту защиту обойти. Если можете хранить состояние на сервере, выбирайте синхронизирующий токен.

SameSite сообщает браузеру, когда прикреплять cookie к запросу, начатому на другом сайте. У атрибута три значения.

  • Strict отправляет cookie только для запросов, начатых на том же сайте. Ссылка из письма в приложение придёт без cookie, поэтому при первом визите пользователь будет выглядеть не вошедшим.
  • Lax отправляет cookie для запросов с того же сайта и для GET-переходов верхнего уровня с другого сайта, например по ссылке. Для межсайтовых POST-запросов и для запросов, загружающих изображения, фреймы и скрипты с другого сайта, cookie не отправляется. Lax блокирует классическую атаку через форму.
  • None отправляет cookie и на межсайтовых запросах. Для него нужен атрибут Secure, так что cookie ходит только по HTTPS. Используйте None, только когда настоящему межсайтовому сценарию нужна cookie, например встроенному стороннему виджету.

MDN описывает атрибут и его значения в справке по Set-Cookie. Черновик RFC 6265bis определяет «сайт» через регистрируемый домен (публичный суффикс плюс метка слева от него, так что для www.site.example это site.example). Номера портов не учитываются. Поддомены считаются тем же сайтом. Позже в лаборатории это пригодится, потому что localhost и 127.0.0.1 — разные хосты и, следовательно, разные сайты.

SameSite надёжен, но закрывает не всё. Соседний поддомен вроде forum.bank.example находится на одном сайте с bank.example, поэтому атаку оттуда Lax не остановит. Старые браузеры, которые игнорируют атрибут, отправляют cookie куда угодно. И Lax всё равно отправляет cookie при GET-переходах верхнего уровня, поэтому правило про GET остаётся в силе. Считайте SameSite одним из слоёв.

3. Проверки Origin и Referer

Браузеры отправляют заголовок Origin с POST-запросами и с большинством межсайтовых запросов. В заголовке указаны схема, хост и порт страницы, начавшей запрос, поэтому сервер может сравнить его со своим origin. Sec-Fetch-Site даёт похожий сигнал, со значениями вроде same-origin, same-site и cross-site.

Проверка простая. Отклоняйте запросы, меняющие состояние, когда Origin присутствует и не совпадает с вашим origin. Если Origin нет, смотрите Referer. Если на запросе, меняющем состояние, нет обоих, отклоняйте его. Разбирайте заголовок как URL и сравнивайте origin целиком. Проверка по подстроке не работает: Origin: https://bank.example.evil.test содержит ваш домен, но принадлежит кому-то другому.

Проверки origin ловят явные межсайтовые запросы, но зависят от заголовков, которые некоторые клиенты не отправляют, а настройки приватности могут убрать. Используйте их как фильтр и оставьте токен для случаев, которые заголовки упускают.

4. Никогда не меняйте состояние на GET

Это правило, и одним запросом его не протестируешь. Соблюдайте его, потому что с ним каждая другая защита покрывает больше. Маршрут, который меняет состояние только на POST, оставляет злоумышленнику форму для подделки, тег изображения ему уже не подбросить.

Понадобятся Python 3.12 и три окна терминала. Для этой статьи всё запускалось на Python 3.12.10 в Windows 11 с Git Bash. В лаборатории работают одно приложение Flask на 127.0.0.1:5000 и обычная статическая страница на localhost:8001. Именно второй origin делает запрос межсайтовым.

Настройка окружения

Создайте виртуальное окружение и установите Flask. В Linux и macOS активируйте его командой . .venv/bin/activate. В Windows с Git Bash скрипт активации лежит в .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

В этом запуске установились Flask 3.1.3 и Werkzeug 3.1.9.

Пишем приложение

Сохраните это как app.py. Маршрут /login ставит cookie сессии с SameSite=Lax и HttpOnly. Маршрут /transfer ничего не меняет. Он только печатает, пришла ли cookie, и два заголовка запроса, которые помогают прочитать результат.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

Приложение пока ничего не проверяет, и сделано это намеренно. Сначала нужно увидеть, что отправляет браузер, и только потом добавлять защиту.

В первом терминале:

bash
flask --app app run --host 127.0.0.1 --port 5000

Во втором терминале запросите маршрут входа и прочитайте заголовок:

bash
curl -i http://127.0.0.1:5000/login

Вывод этого запуска, без строки Date:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

Здесь главное — строка Set-Cookie. HttpOnly скрывает cookie от JavaScript на странице. Браузер всё равно её отправляет, так что HttpOnly защитой от CSRF не служит. Атрибут, который меняет то, что браузер отправляет между сайтами, называется SameSite=Lax.

Имитируем то, что видит сервер

Прежде чем подключать браузер, отправьте запросы сами, чтобы знать, что записывает сервер:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

Ответы этого запуска, по одной строке на команду:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

Строки, которые терминал Flask напечатал для этих трёх запросов:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

curl не применяет SameSite. Он отправляет всё, что вы передали через -b. Эти строки доказывают, что маршрут читает cookie и заголовки. Что отправит браузер, они не доказывают. Это следующий шаг.

Отдаём форму со второго origin

Создайте папку attacker и сохраните это как attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

В третьем терминале запустите статический сервер из папки, в которой лежит attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

В этом запуске запрос к статическому серверу вернул HTTP 200. Затем страница открывается по адресу http://localhost:8001/attacker/. Обратите внимание на имя хоста. localhost и 127.0.0.1 — разные хосты, поэтому форма с localhost:8001 и хост cookie находятся на разных сайтах. Это и есть нужная межсайтовая конфигурация.

Сравниваем SameSite=Lax и SameSite=None

Сначала откройте в браузере http://127.0.0.1:5000/login, чтобы cookie сохранилась для 127.0.0.1 в этом профиле браузера. Затем откройте http://localhost:8001/attacker/ в том же браузере и нажмите Send transfer. Прочитайте терминал Flask.

С SameSite=Lax межсайтовый POST из формы не должен нести cookie. Ожидаемая строка журнала: cookie_arrived=False. Браузер не включает cookie, потому что Lax не отправляет её на межсайтовых POST-запросах. Для этой статьи шаг в браузере не выполнялся, поэтому сверьте строку журнала с этим ожиданием сами.

Теперь перезапустите приложение с None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

Прочитайте заголовок на новом сервере:

bash
curl -i http://127.0.0.1:5000/login

Вывод этого запуска:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

Одного SameSite=None недостаточно. Браузеры отклоняют cookie с None без Secure, а Secure требует HTTPS. Лаборатория отдаёт обычный HTTP на 127.0.0.1, и сохранит ли браузер cookie с Secure с этого origin, зависит от браузера. Откройте cookie в инструментах разработчика и посмотрите, существует ли она. Если она не сохранилась, в журнале сервера будет cookie_arrived=False, но причиной окажется правило Secure, SameSite тут ни при чём. Если сохранилась, отправьте форму ещё раз. Строка cookie_arrived=True с None означает, что браузер отправил cookie на межсайтовом POST. Из-за такого поведения None рискован для cookie сессии, поэтому используйте Lax или Strict, если настоящему межсайтовому сценарию не нужен None.

Когда закончите, остановите каждый сервер через Ctrl+C.

Что лаборатория не доказывает

Лаборатория показывает, как cookie и SameSite меняют то, что получает сервер. Защиты она не добавляет. Маршрут /transfer по-прежнему принимает любой POST с cookie, так что с cookie SameSite=None поддельная форма сработала бы. Чтобы закрыть брешь, добавьте в форму синхронизирующий токен и проверяйте его в маршруте. Лаборатория останавливается здесь, чтобы сначала вы увидели действие одного атрибута.

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

  • Рассчитывать, что CORS остановит CSRF. CORS определяет, может ли страница прочитать ответ. Браузеру он не мешает отправить простой межсайтовый POST. Запрос всё равно доходит до сервера.
  • Думать, что HttpOnly останавливает CSRF. HttpOnly скрывает cookie от JavaScript. Браузер всё равно прикрепляет её к поддельным запросам.
  • Проверять токен только на некоторых маршрутах. Эндпоинты, которые вызывает AJAX, часто обходятся без проверки. Злоумышленники ищут именно такой маршрут.
  • Класть токен в URL. URL попадают в журналы сервера, заголовки Referer и историю браузера.
  • Делать GET-маршруты, меняющие данные. Тогда для атаки хватит обычного тега изображения, и SameSite=Lax маршрут больше не защитит.
  • Ставить None без Secure. Браузеры такое отклоняют. Если менять атрибут, чтобы починить сломанный сценарий, можно открыть межсайтовый путь, которого вы не планировали.
  • Сравнивать Origin по подстроке. И evil-bank.example, и bank.example.evil.test содержат bank.example. Сравнивайте origin целиком.
  • Полагаться только на SameSite. Он надёжен против посторонних сайтов, но соседние поддомены считаются тем же сайтом, а старые браузеры могут игнорировать атрибут.

Что делать дальше

Прочитайте OWASP cheat sheet от начала до конца. Там описаны схемы токенов, проверки заголовков и случаи, которые каждая из них упускает. Прочитайте черновик RFC 6265bis, чтобы узнать точные правила SameSite, включая значение по умолчанию при отсутствии атрибута и обработку переходов верхнего уровня. Для быстрой справки по значениям атрибута удобнее страница MDN о Set-Cookie.

В своём приложении используйте защиту от CSRF, которую даёт фреймворк, и тестируйте её. Напишите один тест, который отправляет POST на маршрут, меняющий состояние, без токена и ожидает отказа. Напишите второй, который отправляет POST с токеном из другой сессии и тоже ожидает отказа. Если вы работаете с Flask, посмотрите расширение CSRFProtect из Flask-WTF. Смотрите предупреждения о cookie в инструментах разработчика браузера: Chrome сообщает о проблемах с SameSite на панели Issues.

О транспортном слое под этими cookie читайте в статье что делают HTTPS и TLS. Атрибут Secure что-то значит только на зашифрованном соединении. Следующая статья этой серии, как написать первый отчёт по безопасности, показывает, как записать такую находку для команды, которой принадлежит код.

Источники

Источники

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

Ещё по теме: Безопасность веба и API

Безопасность веба и API

Чтение JavaScript с ИИ для поиска endpoints

Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.

Начальный3 мин чтения

Безопасность веба и API

Тестирование частых веб-багов с помощью ИИ

Методика для IDOR, XSS, SSRF и сломанной аутентификации: модель ИИ как читатель и второе мнение, плюс как проверить находку и избежать ложных срабатываний.

Начальный3 мин чтения

Безопасность веба и API

Тестирование API с ИИ: OpenAPI и GraphQL

API описывают свою форму через OpenAPI и introspection в GraphQL. Модель ИИ читает схему и планирует тесты авторизации, которые вы проверяете.

Средний3 мин чтения