Пошук уроків, статей та іншого контенту
Зрозумієте механізм CSRF і налаштуєте захист для застосунків із cookie-автентифікацією.
CSRF (Cross-Site Request Forgery) — це атака, під час якої сторонній сайт змушує браузер користувача виконати запит до вашого застосунку.
Проблема виникає через особливість cookie: браузер автоматично додає cookie до запитів на відповідний домен, навіть якщо запит ініційований іншою сторінкою.
Наприклад, користувач увійшов до інтернет-банкінгу, і браузер зберігає cookie:
session_id=abc123Потім користувач відкриває шкідливий сайт. Цей сайт може спробувати відправити запит:
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="amount" value="10000">
<input type="hidden" name="to" value="attacker">
</form>
<script>
document.forms[0].submit();
</script>Браузер додасть cookie session_id до запиту. Сервер може сприйняти його як справжню дію авторизованого користувача.
Атакер зазвичай:
не знає значення cookie;
не може прочитати відповідь сервера через Same-Origin Policy;
але може змусити браузер виконати запит.
CSRF особливо важливий, якщо автентифікація базується на cookie:
Cookie: session_id=abc123Атака можлива для операцій, які змінюють стан:
створення або видалення ресурсів;
зміна пароля;
зміна email;
переказ коштів;
надсилання повідомлень;
оновлення профілю.
Якщо API використовує токен у заголовку Authorization, наприклад:
Authorization: Bearer eyJhbGciOi...і JavaScript-клієнт додає цей заголовок самостійно, класична CSRF-атака зазвичай не працює. Сторонній сайт не може прочитати токен і додати довільний заголовок до запиту.
Однак для застосунків із cookie-автентифікацією CSRF-захист потрібно налаштувати явно.
Поширений підхід — double submit cookie.
Він використовує два значення:
CSRF-токен у cookie;
той самий токен у спеціальному HTTP-заголовку.
Наприклад:
Cookie: session_id=abc123; csrf_token=token-value
X-CSRF-Token: token-valueСервер порівнює ці значення. Якщо вони збігаються, запит проходить перевірку.
Сторонній сайт може спробувати відправити запит, але:
браузер автоматично додасть cookie;
сторонній сайт не може прочитати CSRF-cookie через Same-Origin Policy;
сторонній сайт не зможе встановити правильний X-CSRF-Token.
У production-реалізації токен бажано підписувати секретом сервера. Тоді сервер перевіряє не лише збіг значень, а й те, що токен створив саме він.
CSRF-перевірку зазвичай застосовують до методів, які змінюють стан:
POST;
PUT;
PATCH;
DELETE.
Безпечними в контексті CSRF вважаються:
GET;
HEAD;
OPTIONS.
Це припущення буде коректним лише тоді, коли GET-маршрути справді не змінюють дані.
Створимо middleware, який:
створює CSRF-токен;
встановлює його в cookie;
перевіряє заголовок X-CSRF-Token для небезпечних HTTP-методів;
підписує токен за допомогою HMAC.
Встановіть пакет для роботи з cookie:
npm install cookie
npm install -D @types/cookieСтворіть файл src/security/csrf.middleware.ts:
import {
Injectable,
NestMiddleware,
ForbiddenException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { parse, serialize } from 'cookie';
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'x-csrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
private readonly secret = process.env.CSRF_SECRET;
constructor() {
if (!this.secret) {
throw new Error('CSRF_SECRET не задано');
}
}
use(req: Request, res: Response, next: NextFunction): void {
const cookies = parse(req.headers.cookie ?? '');
let csrfToken = cookies[CSRF_COOKIE_NAME];
if (!csrfToken) {
csrfToken = this.createToken();
res.setHeader(
'Set-Cookie',
serialize(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
}),
);
}
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const requestToken = req.header(CSRF_HEADER_NAME);
if (!requestToken || !this.isValidToken(requestToken, csrfToken)) {
throw new ForbiddenException('Некоректний CSRF-токен');
}
next();
}
private createToken(): string {
const nonce = randomBytes(32).toString('hex');
const signature = this.sign(nonce);
return `${nonce}.${signature}`;
}
private sign(value: string): string {
return createHmac('sha256', this.secret)
.update(value)
.digest('hex');
}
private isValidToken(
requestToken: string,
cookieToken: string,
): boolean {
if (requestToken !== cookieToken) {
return false;
}
const separatorIndex = cookieToken.lastIndexOf('.');
if (separatorIndex === -1) {
return false;
}
const nonce = cookieToken.slice(0, separatorIndex);
const signature = cookieToken.slice(separatorIndex + 1);
if (!/^[a-f0-9]{64}$/.test(signature)) {
return false;
}
const expectedSignature = this.sign(nonce);
return timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex'),
);
}
}У цьому прикладі:
nonce — випадкова частина токена;
signature — HMAC-підпис, створений із секретом сервера;
cookie доступна JavaScript-коду, тому httpOnly має значення false;
cookie не містить сесійних даних;
для небезпечних методів потрібен заголовок X-CSRF-Token.
У src/main.ts підключіть middleware до застосунку:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CsrfMiddleware } from './security/csrf.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(new CsrfMiddleware().use.bind(new CsrfMiddleware()));
await app.listen(3000);
}
bootstrap();Краще створити один екземпляр middleware, щоб не викликати конструктор двічі:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CsrfMiddleware } from './security/csrf.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const csrfMiddleware = new CsrfMiddleware();
app.use(csrfMiddleware.use.bind(csrfMiddleware));
await app.listen(3000);
}
bootstrap();Задайте секрет у змінній середовища:
CSRF_SECRET="довгий-випадковий-секрет" npm run start:devСекрет має бути:
достатньо довгим;
випадковим;
недоступним клієнту;
однаковим для всіх екземплярів застосунку, якщо застосунок працює на кількох серверах.
Створимо контролер із маршрутом читання та маршрутом зміни даних:
import { Controller, Get, Post } from '@nestjs/common';
@Controller('profile')
export class ProfileController {
@Get()
getProfile() {
return {
username: 'olena',
notificationsEnabled: true,
};
}
@Post('notifications')
enableNotifications() {
return {
message: 'Сповіщення увімкнено',
};
}
}Запит GET /profile не потребує CSRF-заголовка.
Запит POST /profile/notifications потребує:
POST /profile/notifications
Cookie: session_id=abc123; csrf_token=...
X-CSRF-Token: ...Якщо заголовок відсутній або його значення неправильне, сервер поверне помилку 403 Forbidden.
Оскільки CSRF-cookie не має прапорця HttpOnly, клієнтський JavaScript може її прочитати.
Наприклад:
function getCookie(name) {
const cookies = document.cookie.split('; ');
for (const cookie of cookies) {
const separatorIndex = cookie.indexOf('=');
const key = cookie.slice(0, separatorIndex);
const value = cookie.slice(separatorIndex + 1);
if (key === name) {
return decodeURIComponent(value);
}
}
return null;
}
async function enableNotifications() {
const csrfToken = getCookie('csrf_token');
const response = await fetch('/profile/notifications', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken,
},
});
if (!response.ok) {
throw new Error('Не вдалося увімкнути сповіщення');
}
return response.json();
}credentials: 'include' потрібен, якщо клієнт і API працюють на різних origin або якщо ви хочете явно дозволити використання cookie у fetch.
CSRF-cookie та cookie автентифікації мають різне призначення.
Cookie автентифікації повинна бути недоступною JavaScript-коду:
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=LaxCSRF-cookie потрібно прочитати клієнту, тому для неї HttpOnly не встановлюють:
Set-Cookie: csrf_token=...; Secure; SameSite=LaxРекомендовані атрибути:
Secure — cookie передається лише через HTTPS;
SameSite=Lax або SameSite=Strict — браузер обмежує міжсайтову передачу cookie;
Path=/ — cookie доступна для всіх маршрутів застосунку;
відсутність Domain — cookie залишається host-only і не поширюється на піддомени.
SameSite є важливим додатковим захистом, але не повинен бути єдиним захистом від CSRF. Політика браузера може відрізнятися, а вимоги застосунку можуть змінитися.
Для додаткового захисту можна перевіряти Origin або Referer у небезпечних запитах.
Наприклад, дозволяти запити лише з власного frontend-origin:
import {
ForbiddenException,
Injectable,
NestMiddleware,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class OriginMiddleware implements NestMiddleware {
private readonly allowedOrigin = 'https://app.example.com';
use(req: Request, res: Response, next: NextFunction): void {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const origin = req.header('origin');
if (origin && origin !== this.allowedOrigin) {
throw new ForbiddenException('Недозволений Origin');
}
}
next();
}
}Перевірка Origin не замінює CSRF-токен, але створює додатковий бар’єр. Якщо застосунок має кілька дозволених frontend-origin, їх потрібно зберігати в явному списку, а не дозволяти будь-яке значення.
Якщо frontend та API мають різні origin, CORS потрібно налаштовувати з конкретним origin:
app.enableCors({
origin: 'https://app.example.com',
credentials: true,
});Не слід поєднувати:
origin: '*',
credentials: true,Браузери не дозволяють використовувати wildcard-origin разом із credentials. Крім того, явний список дозволених origin допомагає уникнути випадкового відкриття API для сторонніх сайтів.
CSRF-захист не захищає від XSS.
Якщо атакер виконає JavaScript у межах вашого origin, він зможе:
прочитати CSRF-cookie;
додати правильний CSRF-заголовок;
виконувати запити від імені користувача.
Тому CSRF-захист потрібно поєднувати із захистом від XSS:
екранувати користувацькі дані під час виведення;
не вставляти неперевірений HTML;
використовувати Content Security Policy;
не зберігати секретні дані у доступних JavaScript сховищах без потреби.
GETЯкщо всі GET-запити перевіряються однаково, клієнту буде складніше отримувати дані, а кешування та стандартна робота API можуть порушитися.
Водночас GET не повинен змінювати стан. Якщо маршрут змінює дані, використовуйте відповідний небезпечний метод і захищайте його.
Якщо сервер перевіряє тільки наявність cookie, захисту немає: браузер сам додасть cookie до міжсайтового запиту.
Потрібна друга копія токена в заголовку або іншому значенні, яке сторонній сайт не може встановити.
HttpOnly для CSRF-cookieКлієнтський JavaScript не зможе прочитати таку cookie і додати її значення до X-CSRF-Token.
HttpOnly потрібно використовувати для cookie автентифікації, але не для cookie, яку frontend читає як CSRF-токен.
Не додавайте токен до query-параметрів:
POST /profile?csrf_token=...URL може потрапити в історію браузера, журнали сервера, аналітику або заголовок Referer. Використовуйте HTTP-заголовок.
Токен не повинен бути простим лічильником або коротким рядком на кшталт:
csrf123Генеруйте його криптографічно стійким генератором випадкових значень і підписуйте секретом сервера.
Конфігурація з довільним origin і cookie-автентифікацією може відкрити API для небажаних frontend-застосунків.
Дозволяйте лише відомі origin.
CSRF використовує автоматичне надсилання cookie браузером.
Найбільший ризик виникає в застосунках із cookie-автентифікацією.
Захищайте POST, PUT, PATCH і DELETE.
Використовуйте double-submit cookie: токен у cookie та в заголовку.
Підписуйте токен секретом сервера.
Для автентифікаційної cookie використовуйте HttpOnly, а CSRF-cookie залишайте доступною JavaScript-коду.
Налаштовуйте SameSite, Secure, CORS і перевірку Origin.
CSRF-захист не замінює захист від XSS.