Що таке CSRF і як його запобігти?
CSRF (Cross-Site Request Forgery) - це атака, при якій зловмисний сайт змушує браузер користувача відправляти автентифіковані запити на інший сайт без його відома.
Теорія
Коротко
- Браузер автоматично відправляє cookies на будь-який сайт, що їх встановив, незалежно від того, звідки надійшов запит
- Аналогія: дворецький, який віддає ключі від будинку будь-кому, хто запитає, бо адресу знають усі
- Сервер бачить валідний session cookie і не може відрізнити запит з твоєї форми від підробленого з
evil.com - Рішення: прикріплювати до кожного запиту зміни стану унікальний секретний токен, який зловмисник не може прочитати або вгадати
SameSite=Strictна session cookie блокує більшість сучасних CSRF атак без зайвого коду
Швидкий приклад
// Вразливо: перевірки немає - зловмисник на evil.com підробить цей POST
app.post('/transfer', (req, res) => {
const { to, amount } = req.body;
// req.session.user встановлено через cookie - браузер відправляє його автоматично
transferMoney(req.session.user.id, to, amount); // спрацює і для підробленого запиту
res.send('Transfer complete');
});
// Захищено: csurf блокує запити без валідного токена
const csurf = require('csurf');
app.use(csurf({ cookie: true }));
app.post('/transfer', (req, res) => {
// Відсутній або невірний _csrf токен -> 403 Forbidden, сюди запит не дійде
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.send('Transfer complete');
});Різниця в одному рядку middleware. Без нього будь-яка сторінка в мережі може відправити POST на твій endpoint і використати session cookie користувача.
Як працює атака
Браузери дотримуються Same-Origin Policy для читання даних, але cookies з неї виключені. Коли браузер відкриває evil.com і там є <img src="https://bank.com/transfer?to=attacker&amount=1000">, браузер надсилає GET запит і автоматично прикріплює session cookie з bank.com. Сервер банку бачить валідну сесію та обробляє запит.
POST атаки трохи складніше організувати, але вони так само ефективні:
<!-- На evil.com -->
<form id="f" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000" />
</form>
<script>document.getElementById('f').submit();</script>
<!-- Користувач нічого не бачить. Переказ проходить. -->Користувач відкриває сторінку, форма відправляється у фоні, гроші переказані. Сервер не отримує жодного сигналу про те, що форма була на evil.com, а не на bank.com.
Коли застосовувати захист
- Операції зміни стану (POST, PUT, DELETE) на автентифікованих endpoints: токен обов'язковий
- JSON API:
Content-Type: application/jsonсам по собі не захищає (дивись типові помилки нижче) - Read-only GET endpoints: захист не потрібен, але переконайся що вони справді тільки читають
- Single-page застосунки: поєднуй
SameSite=Strictcookies з кастомним заголовкомX-CSRF-Token - Мікросервіси з JWT: зберігай токен у заголовку
Authorization, а не в cookie, і CSRF поверхня зникає
Методи захисту
CSRF токени - найпоширеніший підхід. Сервер генерує випадкове значення, прив'язане до сесії, і вбудовує його в кожну форму. При відправці перевіряє збіг. Зловмисник на evil.com не може прочитати це значення через Same-Origin Policy.
// Express + csurf (csurf v1.11.0)
const csurf = require('csurf');
app.use(csurf({ cookie: true }));
// Передаємо токен у React SPA
app.get('/csrf', (req, res) => res.json({ token: req.csrfToken() }));
// React форма, яка отримує токен при монтуванні
function ProfileForm() {
const [csrfToken, setCsrfToken] = useState('');
useEffect(() => {
fetch('/csrf').then(r => r.json()).then(d => setCsrfToken(d.token));
}, []);
return (
<form action="/profile" method="POST">
<input type="hidden" name="_csrf" value={csrfToken} />
<input name="email" />
<button>Оновити</button>
</form>
);
// Підроблений POST з evil.com -> 403, токена немає
}SameSite cookies - сучасне доповнення. Встанови sameSite: 'strict' на session cookie і браузер просто не відправлятиме його на cross-site запити.
res.cookie('sessionId', id, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});Strict блокує відправку cookie на всіх cross-site запитах. Lax дозволяє cookies при top-level GET навігації (перехід за посиланням), але блокує cross-site POST. None відправляє cookies скрізь і вимагає Secure.
Часто бачу підхід: поставили SameSite і вважають що цього досить. На сучасному Chrome в більшості випадків і справді вистачить. Але Apple повністю виправила реалізацію SameSite тільки в Safari 16.4, а додати токен - це десять хвилин роботи. Краще мати обидва рівні.
Подвійний cookie (double-submit cookie) підходить коли немає серверного session store. Генеруєш випадковий токен, кладеш у cookie і також вимагаєш у заголовку запиту або полі форми. Сервер перевіряє що обидва значення збігаються. Зловмисник не може прочитати cookie з іншого origin, тому не може надати обидва одночасно.
Типові помилки
Захищати форми, але ігнорувати AJAX запити. fetch() з Content-Type: application/json автоматично не захищає. Браузери дозволяють cross-site JSON POST в деяких конфігураціях.
// Зловмисник на evil.com
fetch('https://bank.com/api/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'attacker', amount: 1000 })
});
// Якщо сервер приймає JSON без перевірки X-CSRF-Token - це спрацюєРішення: вимагай X-CSRF-Token у заголовках для всіх API routes, не тільки для HTML форм.
Використовувати статичні токени. Токен корисний тільки якщо він непередбачуваний і прив'язаний до сесії. Захардкоджений рядок типу 'static-token-123' можна вкрасти через XSS або підібрати. Використовуй req.csrfToken(), який генерує нове значення при кожному виклику.
GET запити для зміни стану. Будь-який тег <img> може відправити GET запит cross-site без будь-якої взаємодії з користувачем.
// Неправильно: зміна стану через GET
app.get('/user/delete', (req, res) => deleteUser(req.session.userId));
// Правильно: POST з CSRF токеном
app.post('/user/delete', csrfProtection, (req, res) => deleteUser(req.session.userId));Покладатися тільки на заголовки Origin/Referer. Проксі та інструменти приватності видаляють ці заголовки. Деякі браузери не відправляють їх взагалі. OWASP позначає це як ненадійний самостійний захист. Використовуй як додатковий рівень поверх токенів.
Встановити SameSite=Lax скрізь і вважати себе захищеним. Lax все ще дозволяє cookies при top-level POST навігації, тобто шкідливе посилання може спрацювати в деяких випадках.
Де застосовується
- Express.js: middleware
csurf, використовується в KeystoneJS і понад 1M npm проектів - Django: декоратор
@csrf_protect, увімкнений глобально за замовчуванням (Instagram admin) - Rails:
protect_from_forgery, вбудований (GitHub, Shopify) - Spring Boot:
@EnableWebSecurityзCsrfTokenRepository - Next.js:
next-csrfдля API routes у Vercel шаблонах
Для мікросервісів з JWT: зберігай токен у заголовку Authorization. Браузер не прикріплює кастомні заголовки до cross-site запитів автоматично, тому CSRF вектор відсутній.
Питання на співбесіді
Q: Яка різниця між CSRF і XSS?
A: XSS впроваджує шкідливий код прямо на твій сайт і може вкрасти токени безпосередньо. CSRF використовує наявну автентифікацію з іншого origin, нічого не впроваджуючи. XSS порушує Same-Origin Policy. CSRF експлуатує виключення для cookies в ній.
Q: Як SameSite=Strict запобігає CSRF?
A: Браузер не відправлятиме session cookie на жодному cross-site запиті, включаючи підроблені POST форми. Без cookie сервер бачить неавтентифікований запит і відхиляє його. Chrome встановив SameSite=Lax за замовчуванням починаючи з версії 80.
Q: Що таке техніка double-submit cookie?
A: Генеруєш випадковий токен, кладеш у cookie і також вимагаєш у заголовку запиту або полі форми. Сервер перевіряє що обидва значення збігаються. Зловмисник не може прочитати cookie з іншого origin, тому не може надати обидва значення одночасно.
Q: Чому не можна покладатися тільки на заголовки Origin і Referer?
A: Проксі та інструменти приватності видаляють ці заголовки. Деякі браузери не відправляють їх взагалі. OWASP позначає це як ненадійний самостійний захист. Використовуй як додатковий рівень поверх токенів.
Q: Як організувати захист від CSRF в мікросервісах з JWT на рівні API gateway?
A: Зберігай JWT у заголовку Authorization, а не в cookie. Браузер не додає кастомні заголовки до cross-site запитів автоматично, тому CSRF ризику немає. Якщо JWT треба зберігати в cookie, додай підписаний заголовок X-CSRF-Token і валідуй його на кожному сервісі.
Приклади
Базовий: Як виглядає атака
<!-- evil.com - тиха авто-відправка при завантаженні сторінки -->
<form id="f" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker@evil.com" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('f').submit();</script>
<!-- Користувач бачить порожню сторінку. Переказ вже пройшов. -->Якщо bank.com не перевіряє CSRF токен, переказ виконується. Браузер зробив саме те, що йому сказали, з повною автентифікацією в запиті.
Середній: Захищена форма переказу в React
// Клієнт: отримує CSRF токен перед рендером форми
function TransferForm() {
const [token, setToken] = useState('');
const [to, setTo] = useState('');
const [amount, setAmount] = useState('');
useEffect(() => {
fetch('/csrf').then(r => r.json()).then(d => setToken(d.token));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token // зловмисник на evil.com не може отримати це значення
},
body: JSON.stringify({ to, amount })
});
};
return (
<form onSubmit={handleSubmit}>
<input value={to} onChange={e => setTo(e.target.value)} placeholder="Отримувач" />
<input value={amount} onChange={e => setAmount(e.target.value)} placeholder="Сума" />
<button type="submit">Перевести</button>
</form>
);
}// Сервер: видає токен і перевіряє при кожному POST
app.get('/csrf', csrfProtection, (req, res) => res.json({ token: req.csrfToken() }));
app.post('/api/transfer', csrfProtection, (req, res) => {
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.json({ status: 'ok' });
// 403 якщо X-CSRF-Token відсутній або не збігається з токеном сесії
});Розширений: Три рівні захисту в Express
const express = require('express');
const csurf = require('csurf');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Рівень 1: SameSite session cookie - блокує більшість browser-based атак
app.use((req, res, next) => {
res.cookie('sessionId', req.sessionID, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
next();
});
// Рівень 2: CSRF токен - перехоплює те, що пропустив SameSite
const csrfProtection = csurf({ cookie: true });
// Рівень 3: перевірка Origin як додатковий бар'єр
const checkOrigin = (req, res, next) => {
const origin = req.headers.origin;
if (origin && !['https://myapp.com'].includes(origin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
next();
};
// Три рівні послідовно на чутливому route
app.post('/api/transfer', checkOrigin, csrfProtection, (req, res) => {
transferMoney(req.session.user.id, req.body.to, req.body.amount);
res.json({ status: 'ok' });
});
// Endpoint для токена в SPA
app.get('/csrf', csrfProtection, (req, res) => {
res.json({ token: req.csrfToken() });
});Щоб обійти цей захист, зловмиснику треба одночасно обійти обмеження SameSite cookie, вгадати CSRF токен і підробити заголовок Origin. На практиці подолати перші два вже нереалістично.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.