Что такое CSRF

CSRF, или Cross-Site Request Forgery, — это атака, при которой чужой сайт заставляет браузер пользователя отправить запрос в ваше приложение. Главная деталь: браузер автоматически прикладывает к запросу cookies, в том числе session cookie. Если пользователь уже залогинен, сервер может увидеть запрос как «обычный запрос от этого пользователя».

Типичный пример — опасная ссылка или форма на чужой странице:

<form action="https://bank.example/transfer" method="post">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>

Если bank.example принимает перевод только по cookie-сессии и не проверяет дополнительный признак намерения пользователя, запрос может пройти. Это не SQL injection и не XSS: attacker не обязательно читает ответ и не обязательно внедряет код в вашу страницу. Он использует доверие сервера к браузеру жертвы. Но связь с XSS, экранирование вывода и шаблоны важная: XSS часто обходит CSRF-защиту, потому что код уже выполняется внутри доверенного origin.

sequenceDiagram participant A as Сайт attacker.example participant B as Браузер пользователя participant P as PHP-приложение A->>B: HTML-форма или ссылка на state-changing URL B->>P: Запрос + session cookie пользователя P->>P: Проверка CSRF-токена / Origin / метода alt Токен валиден P-->>B: Действие выполнено else Токена нет или он неверный P-->>B: 403 Forbidden end
sequenceDiagram
    participant A as Сайт attacker.example
    participant B as Браузер пользователя
    participant P as PHP-приложение
    A->>B: HTML-форма или ссылка на state-changing URL
    B->>P: Запрос + session cookie пользователя
    P->>P: Проверка CSRF-токена / Origin / метода
    alt Токен валиден
        P-->>B: Действие выполнено
    else Токена нет или он неверный
        P-->>B: 403 Forbidden
    end
CSRF использует автоматическую отправку cookies браузером; защита добавляет серверную проверку намерения пользователя.
Быстрое повторение
Почему CSRF-запрос может выглядеть для сервера как обычное действие залогиненного пользователя?

State-changing запросы и почему GET не подходит

State-changing запрос — это запрос, который меняет состояние: создаёт заказ, удаляет запись, меняет email, выходит из аккаунта, переводит деньги, включает настройку, подтверждает действие. Такие операции не должны висеть на GET.

<?php

// Плохо: переход по ссылке удаляет запись.
// /posts/delete.php?id=42
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    deletePost((int) $_GET['id']);
}

GET считается safe method в смысле HTTP: клиент, прокси, предпросмотр ссылок, бот или браузерная оптимизация вправе обращаться к нему как к чтению. Это не значит, что GET технически не может поменять базу. Это значит, что приложение нарушает контракт. Для изменения состояния используйте POST, PUT, PATCH или DELETE и всё равно защищайте эти запросы от CSRF.

Сама проверка метода не является CSRF-защитой. Чужой сайт может отправить POST-форму. Метод нужен как базовая гигиена HTTP, а токен — как доказательство, что форма была выдана вашим приложением. Разбор входных параметров остаётся темой GET, POST и фильтрация ввода: CSRF-токен не валидирует amount, email или post_id.

Быстрое повторение
Почему удаление записи через `GET /posts/delete.php?id=42` — плохой дизайн даже если обработчик проверяет права пользователя?

Synchronizer token pattern

Классическая защита для PHP-приложения с серверной сессией — synchronizer token. Сервер генерирует случайный токен, сохраняет его в $_SESSION, вставляет в HTML-форму, а при отправке сравнивает значение из формы со значением из сессии.

<?php

session_start();

if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

function csrfToken(): string
{
    return $_SESSION['csrf_token'];
}

В форме:

<form action="/profile/email" method="post">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrfToken(), ENT_QUOTES, 'UTF-8') ?>">
    <input type="email" name="email" required>
    <button type="submit">Сохранить</button>
</form>

На обработчике:

<?php

session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$sentToken = $_POST['csrf_token'] ?? '';
$sessionToken = $_SESSION['csrf_token'] ?? '';

if (!is_string($sentToken) || !hash_equals($sessionToken, $sentToken)) {
    http_response_code(403);
    exit('Invalid CSRF token');
}

// Здесь уже валидируйте email как данные предметной области.

hash_equals() нужен для timing-safe сравнения строк. random_bytes() даёт криптографически стойкие байты; не надо строить токен из времени, user id или mt_rand(). Токен не кладут в URL: query string легко попадает в историю браузера, логи, аналитику и Referer.

Токен можно делать на сессию или на конкретный запрос. Per-request токены строже, но чаще ломают кнопку Back, несколько открытых вкладок и повторную отправку формы. Для обычных CRUD-форм per-session токен обычно практичнее; для особенно чувствительных действий добавляют повторный ввод пароля, WebAuthn, одноразовое подтверждение или отдельный короткоживущий токен.

Быстрое повторение
Как synchronizer token pattern защищает PHP-форму с серверной сессией?

Double-submit cookie

Если сервер не хранит состояние CSRF-токена, используют double-submit cookie pattern. Идея: сервер ставит cookie с CSRF-значением, а клиент отправляет это же значение отдельно — например, в скрытом поле формы или HTTP-заголовке. Сервер сравнивает cookie и параметр.

Наивный вариант «cookie равно полю» слабее, чем кажется: если attacker может инжектить cookie через поддомен или другую ошибку конфигурации, он может подготовить совпадающую пару. Поэтому для серьёзного stateless-варианта токен подписывают HMAC и привязывают к session-specific значению. Если приложение уже использует обычные PHP-сессии, проще и надёжнее начать с synchronizer token.

SameSite: полезно, но не вместо токена

Cookie-атрибут SameSite управляет тем, будет ли браузер отправлять cookie в cross-site запросах. Для session cookie обычно рассматривают Lax или Strict:

<?php

session_set_cookie_params([
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Lax',
]);

session_start();

HttpOnly защищает cookie от чтения через JavaScript, Secure требует HTTPS, SameSite=Lax уменьшает риск классических CSRF-сценариев. Но SameSite не стоит считать единственной защитой. У приложения могут быть старые браузеры, сложные OAuth-флоу, поддомены, embedded-сценарии, API-клиенты или места, где cookie всё же отправляется. Токен остаётся явной проверкой намерения пользователя. Детали cookie и сессий см. в Куки и сессии, а production-настройки — в Конфигурация безопасности PHP.

Origin и Referer как дополнительный слой

Для state-changing запросов можно проверять Origin, а если его нет — аккуратно использовать Referer. Сервер ожидает, что запрос пришёл с вашего origin:

<?php

function sameOriginRequest(string $expectedOrigin): bool
{
    $origin = $_SERVER['HTTP_ORIGIN'] ?? null;

    if (is_string($origin)) {
        return hash_equals($expectedOrigin, $origin);
    }

    $referer = $_SERVER['HTTP_REFERER'] ?? null;
    if (!is_string($referer)) {
        return false;
    }

    $parts = parse_url($referer);
    if (!$parts || !isset($parts['scheme'], $parts['host'])) {
        return false;
    }

    $actual = $parts['scheme'] . '://' . $parts['host'];
    return hash_equals($expectedOrigin, $actual);
}

Это defense in depth, а не замена токенам: заголовки могут отсутствовать из-за политики браузера, корпоративного прокси или приватности. Проверяйте их там, где можете, логируйте странные случаи, но не строите всю защиту только на них. Доступ к таким данным в PHP идёт через $_SERVER, что связано с SAPI и суперглобалы.

AJAX, API и кастомные заголовки

Для HTML-форм скрытое поле нормально. Для fetch() и API часто кладут CSRF-токен в кастомный заголовок:

X-CSRF-Token: 9c3d...

Браузер не позволит обычной HTML-форме с чужого сайта поставить произвольный кастомный заголовок. Но как только вы включаете CORS с credentials, правила становятся тоньше: нельзя бездумно разрешать Access-Control-Allow-Origin: *, нельзя разрешать credentials для непроверенных origin, и нужно понимать, какие клиенты действительно должны иметь доступ. Заголовки ответа и статус-коды здесь пересекаются с HTTP-заголовки, ответы и редиректы.

Короткий чеклист

Все операции изменения состояния — не через GET. Каждая HTML-форма, которая меняет состояние, получает CSRF-токен. Обработчик проверяет метод, токен через hash_equals(), затем уже валидирует бизнес-данные. Токен не передаётся в URL. Session cookie получает HttpOnly, Secure и подходящий SameSite. Для важных операций добавляется проверка Origin/Referer и, если нужно, повторная авторизация пользователя.

CSRF-защита не отвечает на вопрос «имеет ли пользователь право удалить эту запись». Она отвечает на более узкий вопрос: «похоже ли, что этот state-changing запрос был создан страницей нашего приложения, а не чужим сайтом через браузер жертвы». Авторизация, валидация ввода и безопасный вывод остаются отдельными слоями.

См. также

Источники

  1. OWASP Cheat Sheet Series: Cross-Site Request Forgery Prevention
  2. MDN: Safe HTTP methods
  3. MDN: Set-Cookie header
  4. MDN: Origin header
  5. MDN: Referer header
  6. PHP Manual: session_set_cookie_params
  7. PHP Manual: hash_equals