Перейти к содержимому

13. CSRF: атаки и защита

CSRF (Cross-Site Request Forgery) — атака, при которой злоумышленник заставляет браузер авторизованного пользователя выполнить нежелательные действия на другом сайте.

Сценарий атаки:

  1. Пользователь залогинен в bank.com (куки активны)
  2. Пользователь открывает evil.com
  3. evil.com содержит скрытую форму или изображение, отправляющее запрос на bank.com
  4. Браузер автоматически включает куки bank.com в запрос
  5. bank.com думает, что это легитимный запрос от пользователя
<!-- evil.com — невидимая форма автоматически отправляется -->
<form action="https://bank.com/transfer" method="POST" id="evil">
<input name="to" value="attacker-account">
<input name="amount" value="10000">
</form>
<script>document.getElementById('evil').submit();</script>

Важно: CSRF работает именно потому, что браузер автоматически отправляет куки — включая куки сессии!

SameSite атрибут куки контролирует, когда браузер отправляет куку.

res.cookie('sessionId', token, {
sameSite: 'Lax', // защита от CSRF!
httpOnly: true,
secure: true,
});
SameSiteОписаниеCSRF защита
StrictКука не отправляется никогда с других сайтовМаксимальная
LaxОтправляется только при навигации GET (ссылки)Хорошая (default)
NoneВсегда отправляется (нужен Secure)Нет защиты

С 2020 года Lax — значение по умолчанию в Chrome. Это уже частичная защита.

Классический метод: уникальный токен в каждой форме.

import crypto from 'crypto';
// Генерация CSRF токена
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
// Middleware: добавляем токен в сессию и cookie
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
// Доступный JavaScript cookie (не httpOnly!)
res.cookie('csrf-token', req.session.csrfToken, {
httpOnly: false, // JavaScript должен читать его
secure: true,
sameSite: 'Strict',
});
next();
});
// Middleware: проверка CSRF токена для изменяющих запросов
function csrfProtection(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(); // читающие запросы не проверяем
}
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
app.use(csrfProtection);
<!-- В форме -->
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="amount">
<button type="submit">Перевести</button>
</form>

Токен в cookie + токен в запросе:

// Клиент читает CSRF из cookie и добавляет в заголовок
function getCookie(name) {
return document.cookie.split(';')
.find(c => c.trim().startsWith(name + '='))
?.split('=')[1];
}
// Axios interceptor
axios.interceptors.request.use(config => {
if (!['get', 'head', 'options'].includes(config.method)) {
config.headers['X-CSRF-Token'] = getCookie('csrf-token');
}
return config;
});
// fetch wrapper
async function secureFetch(url, options = {}) {
const csrfToken = getCookie('csrf-token');
return fetch(url, {
...options,
headers: {
...options.headers,
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
});
}
Окно терминала
# csurf устарел, используй csrf пакет
npm install csrf
import csrf from 'csrf';
import cookieParser from 'cookie-parser';
const tokens = new csrf();
app.use(cookieParser());
// Генерируем секрет при первом визите
app.use((req, res, next) => {
let secret = req.cookies['csrf-secret'];
if (!secret) {
secret = tokens.secretSync();
res.cookie('csrf-secret', secret, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
});
}
// Создаём токен из секрета
res.locals.csrfToken = tokens.create(secret);
next();
});
// Проверяем токен
app.use((req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const secret = req.cookies['csrf-secret'];
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (!tokens.verify(secret, token)) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
});
function checkOrigin(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.headers.origin;
const referer = req.headers.referer;
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
];
const requestOrigin = origin || (referer && new URL(referer).origin);
if (!requestOrigin || !allowedOrigins.includes(requestOrigin)) {
return res.status(403).json({ error: 'Cross-site request blocked' });
}
next();
}
// middleware.ts или в каждом API роуте
import { NextRequest, NextResponse } from 'next/server';
function isCsrfSafe(req: NextRequest): boolean {
// GET, HEAD, OPTIONS — безопасные
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return true;
// Проверяем Origin
const origin = req.headers.get('origin');
const host = req.headers.get('host');
if (origin) {
const originHost = new URL(origin).host;
return originHost === host;
}
// Проверяем Referer как fallback
const referer = req.headers.get('referer');
if (referer) {
const refererHost = new URL(referer).host;
return refererHost === host;
}
return false;
}
export function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/api/')) {
if (!isCsrfSafe(req)) {
return NextResponse.json(
{ error: 'Cross-site request blocked' },
{ status: 403 }
);
}
}
}

JWT в Authorization заголовке — естественная защита от CSRF:

// CSRF атака не работает: злоумышленник не может прочитать localStorage
// и не может добавить Authorization заголовок в cross-site запросе
fetch('/api/transfer', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount: 100 }),
});

Но! Хранение JWT в localStorage уязвимо к XSS. Гибрид: refresh token в httpOnly SameSite cookie + access token в памяти.

<!-- Тест: открой это в другой вкладке пока залогинен -->
<!DOCTYPE html>
<html>
<body>
<form action="https://yourapp.com/api/change-email" method="POST">
<input name="email" value="[email protected]">
</form>
<script>document.forms[0].submit();</script>
</body>
</html>

Если запрос прошёл — у тебя CSRF уязвимость!

  • Куки с SameSite=Lax (минимум) или Strict
  • CSRF токен в формах (если нет SameSite)
  • Origin/Referer проверка для API
  • JWT в заголовке вместо куки (для API)
  • Важные действия требуют повторного подтверждения пароля
  1. Настрой SameSite=Strict для сессионных куки
  2. Реализуй CSRF токен для HTML форм в Express
  3. Добавь Origin проверку для API в Next.js middleware
  4. Протестируй свой сайт на CSRF уязвимость