● CWE-352 · OWASP A01:2021

Межсайтовая подделка запросов (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, если его не блокирует атрибут cookie SameSite.
Атрибут 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 принимает не только тело формы, но и строку запроса.

Пошаговый сценарий атаки

Шаг 1

Жертва вошла в систему

У неё есть cookie сессии для app.example, отправляемый с SameSite=None или без атрибута в браузере, который не применяет Lax по умолчанию.

Шаг 2

Она открывает страницу атакующего

На ней скрытая форма, которая отправляется на https://app.example/account/email сама при загрузке. Форма с безобидным тестовым значением email=test@example.com показывает этот случай.

Шаг 3

Браузер отправляет запрос вместе с cookie

Запрос несёт сессию жертвы, и ничто не говорит серверу, что форма находилась на другом сайте.

Шаг 4

Сервер меняет учётную запись

Адрес почты становится адресом атакующего, который затем сбрасывает пароль и захватывает аккаунт. В 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"}

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →