Межсайтовая подделка запросов (CSRF, CWE-352): как работает и как предотвратить
Страница на чужом сайте может заставить браузер вошедшего пользователя отправить в ваше приложение запрос, меняющий данные, а браузер сам приложит cookie сессии, поэтому маршрут, проверяющий только cookie, выполнит действие. Как это останавливают синхронизирующие токены, cookie с SameSite и проверка Sec-Fetch-Site, на примере Flask и Flask-WTF.
Простыми словами (ELI5)
В вашем офисе есть экспедиция, которая выполняет любую подписанную заявку из вашего лотка для исходящих, и доверяет заявкам, потому что они лежат в вашем лотке, до которого дотягиваетесь только вы. Посетитель просит вас по пути положить в лоток запечатанный конверт. Внутри заявка: «Смените адрес доставки всех моих посылок». Экспедиция находит её в вашем лотке, считает, что её написали вы, и выполняет. CSRF работает так же: браузер добавляет ваш cookie сессии к каждому запросу в ваш банк, в том числе к запросу, который запустил другой сайт. Исправление — секретный штамп, который могут поставить на заявку только страницы вашего собственного офиса, чтобы экспедиция отклоняла заявки без него.
Ключевые понятия и термины
- Фоновые учётные данные
- Браузеры прикладывают cookie и HTTP-аутентификацию к запросам на сайт независимо от того, какая страница их запустила. Запрос с
evil.exampleнаapp.exampleвсё равно несёт cookie сессииapp.example, если его не блокирует атрибут cookieSameSite. - Атрибут cookie SameSite
Strictне отправляет cookie ни в одном межсайтовом запросе,Laxотправляет его только при переходах верхнего уровня методом GET, аNoneотправляет всегда (и требуетSecure). Chromium считает cookie без атрибутаLax, Firefox и Safari — нет, а «тот же сайт» включает и соседние поддомены.- Синхронизирующий токен
- Случайное значение, привязанное к сессии, которое сервер вставляет в каждую форму или страницу и требует в каждом небезопасном запросе. Чужой сайт не может его прочитать и поэтому не может подделать корректный запрос.
CSRFProtectиз Flask-WTF выдаёт его черезcsrf_token()и проверяет полеcsrf_tokenили заголовокX-CSRFToken. - Fetch Metadata (Sec-Fetch-Site)
- Современные браузеры отправляют в каждом запросе
Sec-Fetch-Site: cross-site,same-site,same-originилиnone, и страницы не могут его подделать. Отклонение небезопасных методов, не являющихсяsame-origin, блокирует CSRF одной проверкой на сервере. - Изменение состояния через GET
- Маршрут, меняющий данные по GET, можно запустить тегом
<img>или ссылкой, аSameSite=Laxвсё равно отправляет cookie при переходах верхнего уровня методом GET. Чтениеrequest.valuesпринимает не только тело формы, но и строку запроса.
Пошаговый сценарий атаки
Жертва вошла в систему
У неё есть cookie сессии для app.example, отправляемый с SameSite=None или без атрибута в браузере, который не применяет Lax по умолчанию.
Она открывает страницу атакующего
На ней скрытая форма, которая отправляется на https://app.example/account/email сама при загрузке. Форма с безобидным тестовым значением email=test@example.com показывает этот случай.
Браузер отправляет запрос вместе с cookie
Запрос несёт сессию жертвы, и ничто не говорит серверу, что форма находилась на другом сайте.
Сервер меняет учётную запись
Адрес почты становится адресом атакующего, который затем сбрасывает пароль и захватывает аккаунт. В 2008 году Зеллер и Фелтен показали такие уязвимости CSRF у ING Direct, YouTube и The New York Times.
Исходный код: Уязвимый vs Защищённый вариант
# app.py: cookie сессии — единственное доказательство, что пользователь этого хотел
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET тоже работает, а request.values читает ещё и строку запроса
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
# app.py: токен, cookie с SameSite и Fetch Metadata вместе
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# каждому POST нужен csrf_token; формы выводят {{ csrf_token() }}, fetch() шлёт X-CSRFToken
csrf = CSRFProtect(app)
# запись, запущенная другим сайтом, отклоняется до выполнения маршрута
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
abort(403)
# только POST, данные из тела формы
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
Чек-лист по защите системы для инженеров
- Меняйте состояние только методами POST, PUT, PATCH или DELETE и читайте данные формы из тела (
request.form), а не изrequest.valuesили строки запроса. - Включите CSRF-middleware фреймворка для всего приложения (
CSRFProtect,CsrfViewMiddlewareв Django,csrf()в Spring Security) и добавляйте токен в каждую форму и каждый AJAX-запрос. - Задавайте cookie сессии атрибуты
Secure,HttpOnlyиSameSite=LaxилиStrict; используйтеSameSite=Noneтолько для cookie, которые должны работать во встроенных фреймах чужих сайтов. - Отклоняйте небезопасные запросы, у которых заголовок
Sec-Fetch-Siteравенcross-siteилиsame-site. - Добавьте в CI тест, который отправляет каждому изменяющему состояние маршруту запрос без токена и ожидает 400 или 403.