Что такое 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
endState-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.
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, одноразовое подтверждение или отдельный короткоживущий токен.
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 запрос был создан страницей нашего приложения, а не чужим сайтом через браузер жертвы». Авторизация, валидация ввода и безопасный вывод остаются отдельными слоями.
См. также
- GET, POST и фильтрация ввода — валидация параметров после прохождения CSRF-проверки.
- Куки и сессии — session cookie,
HttpOnly,Secureи срок жизни сессии. - XSS, экранирование вывода и шаблоны — почему XSS может обойти CSRF-защиту.
- HTTP-заголовки, ответы и редиректы —
Origin,Referer, CORS и корректные HTTP-ответы. - SAPI и суперглобалы — откуда в PHP берутся
$_SERVER,$_POSTи request metadata. - Конфигурация безопасности PHP — production-настройки, которые дополняют защиту приложения.