CSRF: как перестать быть пешкой в чужой игре

Введение: почему браузер — это не ваш друг

Представьте: пользователь заходит на сайт банка, авторизуется, а потом случайно открывает заранее подготовленную злоумышленником страницу. Браузер, будучи добросовестным помощником, автоматически отправляет все куки, включая сессионные, с каждым запросом — даже если пользователь ничего не нажимал. Вот тут и происходит CSRF: атакующий отправляет POST-запрос на /transfer, а браузер жертвы его выполняет, не спрашивая разрешения.

Это не гипотетический сценарий. В 2021 году через CSRF было украдено $10 млн с помощью фишинговых писем, где жертвы кликали по ссылкам, которые автоматически отправляли транзакции. А в 2023-м исследователи показали, как через CSRF можно было обойти двухфакторную аутентификацию в некоторых сервисах.

Проблема в том, что браузеры не различают "свой" и "чужой" код — они просто выполняют все запросы, которые приходят от открытых страниц. А серверы часто не проверяют, откуда пришёл запрос — только то, что он пришёл с валидными куками.


Как это работает: механика атаки

CSRF атакуют три ключевых свойства веба:

  1. Автоматическая отправка кук — браузер всегда шлёт куки домена, с которого пришёл запрос.
  2. Метод GET по умолчанию — ссылки открываются без подтверждения, а GET-запросы часто считаются безопасными.
  3. Cross-Origin Requests — если жертва авторизована на bank.com, а атакующий контролирует evil.com, он может заставить браузер отправить запрос на bank.com с куками жертвы.

Пример атаки: изменение пароля

Предположим, у вас есть эндпоинт /change-password с параметрами в GET-запросе:

https://example.com/change-password?email=user@example.com&new_password=hacked123

Злоумышленник создаёт страницу на evil.com с такой ссылкой:

<a href="https://example.com/change-password?email=user@example.com&new_password=hacked123" target="_blank">Нажми, чтобы выиграть приз!</a>

Жертва кликает — и её пароль меняется. Без защиты это работает всегда, если пользователь авторизован.


Защита: что работает, а что — нет

❌ Ложные решения (не защищают от CSRF)

  1. Проверка Referer — легко обойти через Referer-спуфинг или прокси.
    # Плохой пример (Django)
    if request.META.get('HTTP_REFERER') != 'https://yourdomain.com':
        return HttpResponseForbidden()
    
  2. CSRF-токены в GET-параметрах — токен можно угадать или подсмотреть в URL.
  3. Блокировка GET-запросов для изменяющих данные эндпоинтов — не всегда возможно (например, для GET /api/webhooks).

✅ Рабочие методы

1. CSRF-токены в POST-формах

Самый распространённый способ — генерировать уникальный токен для каждой сессии и проверять его в POST-запросах.

Пример для Django (средствами фреймворка):

# settings.py
MIDDLEWARE = [
    'django.middleware.csrf.CsrfViewMiddleware',
    # ...
]

# В шаблоне (template.html):
<form method="post">
    {% csrf_token %}
    <input type="submit" value="Отправить">
</form>

Как это работает:

  • При рендере формы Django добавляет скрытое поле с токеном:
    <input type="hidden" name="csrfmiddlewaretoken" value="u32h9fj32hf93j2hf93jfh23">
    
  • Сервер проверяет, что токен из запроса совпадает с токеном в сессии.

Ограничения:

  • Работает только для POST-форм. Для AJAX нужно отправлять токен в заголовке X-CSRFToken.
  • Не защищает от GET-атак (см. ниже).

2. SameSite-куки

Модернизированный подход — ограничить куки, чтобы браузер не отправлял их с других сайтов.

Конфигурация для Nginx + Django:

location / {
    add_header Set-Cookie "sessionid=...; SameSite=Strict; Secure";
}

Параметры SameSite:

  • Strict — куки отправляются только для запросов с того же сайта.
  • Lax — куки отправляются для "безопасных" навигационных GET-запросов (например, при клике по ссылке).
  • None — куки отправляются всегда, но требует Secure (HTTPS).

Пример для Node.js (Express):

res.cookie('session', 'abc123', {
    sameSite: 'strict',
    secure: true,
    httpOnly: true
});

Эффективность:

  • Защищает от ~90% CSRF-атак, если настроен правильно.
  • Не требует изменений в коде — работает на уровне браузера.

3. Double Submit Cookie

Комбинация токена в куках и в теле запроса. Если они не совпадают — запрос отклоняется.

Пример для Express (Node.js):

const csrf = require('csurf');
const cookieParser = require('cookie-parser');

app.use(cookieParser());
app.use(csrf({ cookie: { httpOnly: false } }));

// В шаблоне:
<form method="post">
    <input type="hidden" name="_csrf" value="<%= req.csrfToken() %>">
    <input type="submit">
</form>

Как это работает:

  1. Сервер генерирует токен и сохраняет его в куки (XSRF-TOKEN).
  2. Клиент отправляет тот же токен в теле запроса (_csrf).
  3. Сервер сравнивает оба значения.

Плюсы:

  • Защищает от GET-атак (если токен не передаётся в URL).
  • Работает с AJAX, если правильно настроить заголовки.

4. Специфичные заголовки

Для API можно требовать наличие заголовка X-Requested-With: XMLHttpRequest или пользовательского заголовка, который клиент должен знать.

Пример для Flask:

from flask import request, abort

@app.route('/api/transfer', methods=['POST'])
def transfer():
    if request.headers.get('X-Custom-Auth') != 'expected-value':
        abort(403)
    # Обработка запроса

Ограничения:

  • Работает только для API, где клиент контролируется (например, мобильное приложение).
  • Легко обойти, если заголовок угадывается.

Типичные ошибки и как их избежать

  1. Забыв защитить GET-эндпоинты с побочными эффектами

    • Плохо: /withdraw?amount=1000 (даже если это GET, деньги уйдут).
    • Решение: Использовать POST или добавлять CSRF-защиту для GET-запросов с токеном в куках.
  2. Использовать Referer вместо SameSite

    • Проблема: Referer легко подделать через прокси или модификацию заголовков.
    • Решение: Перейти на SameSite=Strict + Secure.
  3. Не проверять токен в AJAX-запросах

    • Плохо: Отправлять только куки, забывая про токен в заголовке.
    • Решение: Автоматически добавлять токен в заголовок X-CSRF-Token:
      // jQuery пример
      $.ajaxSetup({
          headers: { 'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val() }
      });
      
  4. Хранить токены в локальном хранилище (localStorage)

    • Проблема: JavaScript-код может украсть токен из localStorage.
    • Решение: Хранить токен только в HttpOnly-куках.
  5. Игнорировать GET-параметры в чувствительных эндпоинтах

    • Пример уязвимости:
      # Django view (уязвим!)
      @csrf_exempt  # ❌ Опасно!
      def sensitive_action(request):
          if request.method == 'GET':
              return do_something_bad(request.GET['param'])
      
    • Решение: Всегда использовать POST для изменяющих данных эндпоинтов.

Практические шаги для защиты проекта

1. Аудит уязвимых эндпоинтов

Пройдитесь по коду и найдите:

  • Все GET-запросы, которые изменяют состояние (например, /delete, /transfer).
  • Формы без CSRF-защиты.
  • API-эндпоинты без проверки заголовков.

Команда для поиска в Django:

grep -r "csrf_exempt" .  # Найдёт все места, где отключена защита
grep -r "method=['\"]GET" .  # Найдёт GET-запросы

2. Настройка SameSite для кук

Обновите конфигурацию сервера и фреймворка:

  • Nginx:
    proxy_cookie_flags ~^(.*?)(;.*?)*$ $1;SameSite=Strict;Secure;
    
  • Django:
    SESSION_COOKIE_SAMESITE = 'Strict'
    CSRF_COOKIE_SAMESITE = 'Strict'
    
  • Express:
    res.cookie('session', '...', { sameSite: 'strict', secure: true });
    

3. Тестирование защиты

Используйте инструменты для проверки:

  • Burp Suite — для ручного тестирования CSRF-атак.
  • OWASP ZAP — для автоматического сканирования.
  • CSRF-токен тестер:
    curl -v -b "sessionid=..." -H "Referer: http://evil.com" http://your-site.com/transfer
    
    Если ответ — 403 Forbidden, защита работает.

4. Документирование и обучение команды

  • Добавьте checklist для ревью кода:
    • Все формы имеют CSRF-токен.
    • Чувствительные GET-эндпоинты защищены.
    • Куки настроены с SameSite=Strict.
  • Проведите обучение по безопасности для backend-разработчиков.

Заключение: CSRF — это не баг, а фича безопасности

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

Что делать прямо сейчас:

  1. Для новых проектов: Включите CSRF-защиту по умолчанию (Django, Flask-WTF, Express-CSRF).
  2. Для старых проектов: Проведите аудит и закройте уязвимости за 2 недели.
  3. Для API: Используйте SameSite-куки + специфичные заголовки.
  4. Для фронтенда: Автоматизируйте добавление CSRF-токенов в AJAX-запросы.

Помните: CSRF-атака работает, только если у вас есть уязвимые эндпоинты. Закройте их — и проблема исчезнет.