Skip to main content

Що таке CSRF і як його запобігти?

CSRF (Cross-Site Request Forgery) - це атака, при якій зловмисний сайт змушує браузер користувача відправляти автентифіковані запити на інший сайт без його відома.

Теорія

Коротко

  • Браузер автоматично відправляє cookies на будь-який сайт, що їх встановив, незалежно від того, звідки надійшов запит
  • Аналогія: дворецький, який віддає ключі від будинку будь-кому, хто запитає, бо адресу знають усі
  • Сервер бачить валідний session cookie і не може відрізнити запит з твоєї форми від підробленого з evil.com
  • Рішення: прикріплювати до кожного запиту зміни стану унікальний секретний токен, який зловмисник не може прочитати або вгадати
  • SameSite=Strict на session cookie блокує більшість сучасних CSRF атак без зайвого коду

Швидкий приклад

javascript
// Вразливо: перевірки немає - зловмисник на 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 атаки трохи складніше організувати, але вони так само ефективні:

html
<!-- На 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=Strict cookies з кастомним заголовком X-CSRF-Token
  • Мікросервіси з JWT: зберігай токен у заголовку Authorization, а не в cookie, і CSRF поверхня зникає

Методи захисту

CSRF токени - найпоширеніший підхід. Сервер генерує випадкове значення, прив'язане до сесії, і вбудовує його в кожну форму. При відправці перевіряє збіг. Зловмисник на evil.com не може прочитати це значення через Same-Origin Policy.

javascript
// 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 запити.

javascript
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 в деяких конфігураціях.

javascript
// Зловмисник на 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 без будь-якої взаємодії з користувачем.

javascript
// Неправильно: зміна стану через 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 і валідуй його на кожному сервісі.

Приклади

Базовий: Як виглядає атака

html
<!-- 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

jsx
// Клієнт: отримує 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> ); }
javascript
// Сервер: видає токен і перевіряє при кожному 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

javascript
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. На практиці подолати перші два вже нереалістично.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?