Веб
CSRF и SameSite cookie: запросы от имени пользователя
Как работает CSRF, насколько от него защищает SameSite, CSRF-токены, API с cookie и bearer-токенами и связь с CORS. С примерами для Laravel и Node.
· 9 мин. чтения

Cross-Site Request Forgery (CSRF) — атака, при которой пользователь, сам того не зная, отправляет на ваш сайт запрос со своей сессией. Пользователь вошёл в вашу систему, а затем открыл другую страницу. Эта страница через скрытую форму отправляет POST на ваш сайт. Браузер по привычке добавляет к запросу cookie вашего сайта — и сервер принимает это за действие настоящего пользователя.
За последние годы браузеры ввели атрибут cookie SameSite, и CSRF стал заметно сложнее. Но вывод «CSRF больше нет» неверен. В этой статье разберём, как работает CSRF, что закрывает SameSite и что не закрывает, а также связь между токенами, API и CORS.
Как выглядит атака
Представьте, что в приложении есть эндпоинт смены email:
POST /profile/email
Cookie: session=...
email=new@example.uz
Атакующий размещает на своей странице:
<form action="https://app.example.uz/profile/email" method="POST" id="f">
<input type="hidden" name="email" value="attacker@evil.example">
</form>
<script>document.getElementById('f').submit();</script>
Если пользователь вошёл в систему, а сервер полагается только на сессионную cookie, email изменится. Затем атакующий захватывает аккаунт через «восстановление пароля». Тем же способом можно переводить деньги, менять роли, удалять данные.
Обратите внимание: атакующий не может прочитать ответ. CSRF — «слепая» атака, её цель не кража данных, а изменение состояния.
Почему это важно для экспертизы
CSRF классифицируется как CWE-352 и в OWASP Top 10 (2021) отнесён к категории A01 «Broken Access Control». OWASP ASVS отдельно требует защиты от CSRF для запросов, меняющих состояние. На экспертизе обычно проверяют:
- Есть ли CSRF-токен в формах, меняющих состояние, и действительно ли сервер его проверяет.
- Установлены ли атрибуты
SameSite,Secure,HttpOnlyу сессионных cookie. - Не меняется ли состояние через
GET-запрос. - Не позволяют ли настройки CORS чужому домену отправить запрос с cookie и прочитать ответ.
SameSite: что закрывает, а что нет
Атрибут SameSite говорит браузеру, добавлять ли cookie к запросам, инициированным с другого сайта:
- Strict — cookie отправляется только в запросах внутри вашего сайта. Даже при переходе по ссылке с другого сайта пользователь окажется «не вошедшим».
- Lax — при обычной навигации с другого сайта (
GETпо ссылке) cookie отправляется, но вPOST-формах,fetch,iframeи картинках — нет. - None — cookie отправляется везде. В этом случае
Secureобязателен.
Большинство современных браузеров считают cookie без атрибута как Lax. Laravel тоже по умолчанию использует lax для сессионной cookie (same_site в config/session.php).
Lax закрывает классический POST-CSRF. Но это не полная защита:
- Изменение состояния через GET. Если
/logout,/delete?id=5или/approve?id=7работают черезGET,Laxотправит cookie, и атака сработает. - Разница между «site» и «origin». SameSite работает на уровне сайта:
app.example.uzиblog.example.uzсчитаются одним сайтом. Если на одном из поддоменов есть XSS или он передан третьей стороне, запрос оттуда будет «same-site», и SameSite не поможет. - Случаи, когда нужен
SameSite=None. Если приложение встраивается в iframe на другом сайте или есть сторонняя интеграция, cookie переводят вNone, и защита пропадает. - Старые браузеры и нестандартные клиенты не всегда трактуют атрибут одинаково.
SameSite — хороший дополнительный уровень, но он не заменяет CSRF-токен.
CSRF-токен: основная защита
Классическая защита — synchronizer token: сервер записывает в сессию случайный токен, добавляет его в каждую форму скрытым полем и проверяет при каждом запросе, меняющем состояние. Чужой сайт не знает этот токен и не может его прочитать.
Laravel
В Laravel CSRF-защита включена по умолчанию в группе middleware web. В формах Blade:
<form method="POST" action="/profile/email">
@csrf
<input type="email" name="email">
</form>
Axios и Inertia читают cookie XSRF-TOKEN и автоматически отправляют её в заголовке X-XSRF-TOKEN. Главное, что нужно проверить, — список исключений. В Laravel 11+ в bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/payment', // только настоящие внешние вебхуки
]);
})
Добавляйте в исключения только вебхуки, которые вызывают внешние сервисы, и проверяйте их подписью (HMAC). 'api/*' или '*' по принципу «не работало — добавил в исключения» — распространённая и серьёзная ошибка.
Node (Express)
Широко использовавшийся раньше пакет csurf помечен как устаревший. Вместо него используйте библиотеки, реализующие шаблон double-submit cookie, или напишите минимальную защиту сами: проверку заголовка Origin и токен.
const ALLOWED_ORIGINS = new Set(['https://app.example.uz']);
function verifyOrigin(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.get('Origin') || '';
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}
app.use(verifyOrigin);
Проверка Origin хорошо работает в современных браузерах: в POST-запросах браузер ставит этот заголовок сам, и JavaScript не может его подделать. Дополнительно можно использовать заголовок Sec-Fetch-Site: отклоняйте запросы, меняющие состояние, со значением cross-site.
Для сессионной cookie:
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));
API: cookie или bearer-токен
Корень CSRF — браузер добавляет cookie автоматически. Поэтому решающее значение имеет то, как API аутентифицирует запросы:
- Bearer-токен (
Authorization: Bearer ...) — браузер сам этот заголовок не добавляет, его должен поставить JavaScript. Чужой сайт токена не знает, значит CSRF практически невозможен. Но если токен хранится вlocalStorage, его может украсть XSS — это другой риск. - API на cookie (SPA, Sanctum stateful) — браузер отправляет cookie автоматически, значит защита от CSRF нужна. Поэтому в режиме SPA в Laravel Sanctum сначала вызывается
/sanctum/csrf-cookie, а следующие запросы отправляются сX-XSRF-TOKEN.
В Sanctum указывайте в SANCTUM_STATEFUL_DOMAINS только свои фронтенд-домены. Для мобильного приложения используйте режим токенов, а не cookie.
Частая ошибка: проверку CSRF отключают по логике «API принимает JSON, значит формой атаковать нельзя». Если сервер не проверяет строго Content-Type и читает тело text/plain или application/x-www-form-urlencoded как JSON, атака обычной формой снова становится возможной.
Связь с CORS
CORS и CSRF часто путают. Важные моменты:
- CORS не защищает от CSRF. CORS управляет только тем, может ли чужая страница прочитать ответ. Простые запросы (
POSTформы,text/plain) отправляются без preflight, и сервер их выполняет — даже если ответ не будет прочитан. - Неправильный CORS усиливает CSRF. Если сервер отражает любой
Originи возвращаетAccess-Control-Allow-Credentials: true, чужой сайт может отправить запрос с cookie и прочитать ответ — включая CSRF-токен. Тогда падает и защита токеном. - Preflight полезен, но не является единственной защитой.
Content-Type: application/jsonили собственный заголовок вызывают preflight, и правильно настроенный CORS его отклонит. Но это работает, только если сервер строго проверяетContent-Type.
В Laravel config/cors.php:
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['https://app.example.uz'],
'supports_credentials' => true,
Нельзя ставить * в allowed_origins и одновременно supports_credentials в true — настройте это правильно.
Как проверить самостоятельно
Действительно ли проверяется токен
Войдите в систему в браузере, через DevTools скопируйте запрос, меняющий состояние («Copy as cURL»), затем отправьте его повторно без токена:
# С токеном — ожидается 200/302
curl -si -X POST https://staging.example.uz/profile/email \
-H "Cookie: laravel_session=..." \
-d "_token=VALID&email=test@example.uz"
# Без токена — ожидается 419 (Laravel) или 403
curl -si -X POST https://staging.example.uz/profile/email \
-H "Cookie: laravel_session=..." \
-d "email=test@example.uz"
Если второй запрос прошёл успешно, защита не работает.
Origin и CORS
curl -si https://staging.example.uz/api/user \
-H "Origin: https://evil.example" \
-H "Cookie: laravel_session=..." | grep -i "access-control"
Если в ответе вместе появляются Access-Control-Allow-Origin: https://evil.example и Access-Control-Allow-Credentials: true, это серьёзная проблема.
Атрибуты cookie
curl -si https://staging.example.uz/login | grep -i set-cookie
У сессионной cookie должны быть Secure, HttpOnly и SameSite=Lax (или Strict).
Изменение состояния через GET
Просмотрите список маршрутов:
php artisan route:list --method=GET | grep -iE "delete|remove|logout|approve|toggle"
Действия, меняющие состояние, должны выполняться через POST, PUT, PATCH или DELETE.
Короткий чек-лист
- Все запросы, меняющие состояние, защищены CSRF-токеном или проверкой
Origin. - В исключениях CSRF только вебхуки, проверяемые подписью.
-
GET-запросы не меняют состояние. - У сессионных cookie установлены
Secure,HttpOnly,SameSite=LaxилиStrict. - Для SPA/API на cookie включена защита от CSRF, stateful-домены Sanctum указаны явно.
- Сервер строго проверяет
Content-Type. - CORS не отражает
Origin, credentials разрешены только для конкретных доменов. - Поддомены под контролем, на них проверено отсутствие XSS.
Вывод
SameSite cookie заметно усложнили CSRF, но не устранили его. Надёжная защита состоит из нескольких уровней: CSRF-токен или проверка Origin, изменение состояния только через POST/PUT/DELETE, правильные атрибуты cookie и строгий CORS. Эти настройки часто уже есть, но перестают работать из-за списка исключений или «временно» отключённой проверки — именно такие места и находит экспертиза.
Хотите проверить CSRF-защиту форм и API до экспертизы? Оставьте заявку на сканирование — Claude AI агент просмотрит маршруты, middleware и настройки cookie, а наш специалист поможет закрыть найденные дыры.


