Пошук уроків, статей та іншого контенту
Розберете CSRF-атаки й реалізуєте захист за допомогою SameSite, CSRF-токенів і перевірки походження запиту.
CSRF (Cross-Site Request Forgery) — атака, за якої зловмисний сайт змушує браузер користувача виконати запит до іншого сайту, де користувач уже автентифікований.
Браузер автоматично додає до запиту cookie потрібного домену. Тому сервер може сприйняти підроблений запит як запит самого користувача.
Наприклад, користувач увійшов у банківський застосунок:
Cookie: session=abc123На іншому сайті зловмисник розміщує форму:
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="recipient" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>
document.querySelector("form").submit();
</script>Браузер може надіслати цю форму разом із cookie session=abc123. Якщо сервер перевіряє лише наявність сесійної cookie, переказ буде виконано від імені користувача.
Same-Origin Policy забороняє сторінці з одного походження читати відповіді іншого походження без відповідного дозволу.
Наприклад, зловмисний сайт зазвичай не може прочитати відповідь:
fetch("https://bank.example/api/profile");Але Same-Origin Policy не забороняє браузеру надіслати сам запит. Для CSRF цього достатньо, якщо запит змінює стан сервера.
Отже:
SOP обмежує читання відповіді;
CSRF використовує можливість надсилати запити;
CORS не є основним захистом від CSRF.
CSRF актуальний, якщо одночасно виконуються такі умови:
Автентифікація зберігається в cookie.
Браузер автоматично додає цю cookie до запиту.
Сервер змінює дані без додаткового підтвердження, наприклад токена.
Запит можна створити з іншого походження.
Типові небезпечні операції:
зміна пароля або email;
переказ коштів;
видалення облікового запису;
зміна прав доступу;
створення замовлення;
зміна налаштувань.
Операції, що змінюють стан, не варто реалізовувати через GET.
GET /api/delete-accountТакий endpoint легко викликати навіть звичайним тегом:
<img src="https://example.com/api/delete-account">Для змін стану використовуйте POST, PUT, PATCH або DELETE. Це саме по собі не усуває CSRF, але зменшує кількість простих способів атаки.
Атрибут SameSite керує тим, чи додається cookie до міжсайтових запитів.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=LaxLax зазвичай дозволяє cookie для навігації верхнього рівня з безпечними методами, наприклад GET, але не надсилає її для більшості міжсайтових POST-запитів.
Це хороший базовий вибір для сесійної cookie.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=StrictStrict суворіший: cookie не надсилається під час переходу на сайт з іншого сайту.
Це може посилити захист, але іноді погіршує користувацький досвід. Наприклад, користувач може перейти на ваш сайт із зовнішнього сервісу, але браузер не надішле сесійну cookie під час такого переходу.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=NoneNone дозволяє надсилати cookie у міжсайтових контекстах. Такий режим потрібен для деяких сценаріїв із вбудованими iframe або сторонніми інтеграціями.
Для SameSite=None обов’язково потрібен Secure, тобто cookie має передаватися лише через HTTPS.
SameSite — важливий шар захисту, але не єдиний:
поведінка залежить від типу навігації;
деякі бізнес-сценарії потребують міжсайтових cookie;
старі або нестандартні клієнти можуть поводитися інакше;
помилкова конфігурація cookie знижує рівень захисту.
Для критичних операцій використовуйте SameSite разом із CSRF-токеном і перевіркою походження запиту.
CSRF-токен — це випадкове значення, яке:
генерує сервер;
пов’язує із сесією користувача;
передає довіреній сторінці;
очікує назад у запиті, що змінює стан.
Зловмисний сайт може спробувати надіслати запит, але не може прочитати токен із вашої сторінки через Same-Origin Policy.
Токен можна передавати:
у заголовку, наприклад X-CSRF-Token;
у прихованому полі HTML-форми;
у JSON-тілі запиту.
Для JavaScript-застосунків зручним є заголовок:
const response = await fetch("/api/profile", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
displayName: "Нове ім’я"
})
});Сторонній сайт не може просто додати такий заголовок до кроссдоменного запиту без CORS-перевірки. А прочитати правильний токен він не може через Same-Origin Policy.
Токен має бути:
криптографічно випадковим;
достатньо довгим;
непередбачуваним;
прив’язаним до сесії або іншого серверного контексту;
перевіреним сервером до виконання операції.
Не використовуйте:
ID користувача;
email;
timestamp;
короткий лічильник;
значення, яке легко вгадати.
Токен не потрібно передавати в URL:
/api/transfer?csrfToken=...URL може потрапити в історію браузера, логи сервера, аналітику або заголовок Referer.
У цьому підході сервер зберігає токен у сесії:
session.csrfToken = randomValueСервер вставляє його в HTML або повертає через захищений endpoint. Клієнт надсилає токен у кожному запиті, що змінює стан.
На сервері перевіряються:
сесійна cookie;
наявність токена;
відповідність токена значенню в сесії.
Якщо хоча б одна перевірка не пройдена, сервер повертає 403 Forbidden.
Інший підхід — Double Submit Cookie:
сервер встановлює CSRF-cookie;
клієнт читає її значення;
клієнт повторює це значення в заголовку або тілі запиту;
сервер порівнює cookie та значення з запиту.
Проста форма цього підходу має недолік: якщо зловмисник може встановити власну cookie для вашого домену, він зможе повторити її значення в запиті.
Тому на практиці токен варто:
підписувати на сервері;
прив’язувати до сесії;
або використовувати synchronizer token pattern.
Сесійну cookie зазвичай позначають HttpOnly, щоб JavaScript не міг її прочитати. CSRF-токен, навпаки, може бути доступним JavaScript, якщо він передається в HTML або окремій відповіді.
Браузер часто надсилає заголовок Origin:
Origin: https://app.exampleСервер може дозволяти лише власне походження:
https://app.exampleВажливо порівнювати походження точно. Не можна перевіряти його простим пошуком підрядка:
// Небезпечно
if (origin.includes("example.com")) {
allowRequest();
}Такий код пропустить, наприклад:
https://example.com.attacker.testПотрібно порівнювати повний origin, що складається зі схеми, хоста та порту.
Зазвичай порядок перевірок такий:
якщо Origin присутній — перевірити його;
якщо Origin відсутній — перевірити Referer;
якщо немає жодного заголовка — для критичних операцій відхилити запит.
Заголовок Referer може бути обрізаний або відсутній через політику приватності, тому його не слід вважати таким самим надійним, як Origin.
Сучасні браузери можуть додавати заголовок:
Sec-Fetch-Site: cross-siteМожливі значення:
same-origin;
same-site;
cross-site;
none.
Сервер може відхиляти небезпечні кроссайтові запити:
Якщо Sec-Fetch-Site == cross-site
і метод змінює стан:
повернути 403Це корисний додатковий захист, але він не повинен бути єдиним механізмом. Потрібно враховувати браузери та клієнти, які не надсилають ці заголовки.
Нижче наведено невеликий сервер на вбудованому модулі Node.js та клієнтський JavaScript. Він демонструє:
сесійну cookie з HttpOnly і SameSite=Lax;
CSRF-токен у HTML;
передачу токена через X-CSRF-Token;
перевірку Origin;
обробку операції, що змінює стан.
Збережіть код у файл server.js і запустіть командою:
node server.jsПотім відкрийте http://localhost:3000.
const http = require("node:http");
const crypto = require("node:crypto");
const PORT = 3000;
const ORIGIN = `http://localhost:${PORT}`;
const sessions = new Map();
function createSession() {
const sessionId = crypto.randomBytes(32).toString("hex");
const csrfToken = crypto.randomBytes(32).toString("hex");
sessions.set(sessionId, {
csrfToken,
displayName: "Старе ім’я"
});
return { sessionId, csrfToken };
}
function parseCookies(request) {
const header = request.headers.cookie || "";
return Object.fromEntries(
header
.split(";")
.map((part) => part.trim().split("="))
.filter(([name, value]) => name && value)
);
}
function getSession(request, response) {
const cookies = parseCookies(request);
let session = sessions.get(cookies.session);
if (!session) {
const created = createSession();
response.setHeader("Set-Cookie", [
`session=${created.sessionId}; HttpOnly; SameSite=Lax; Path=/`
]);
session = sessions.get(created.sessionId);
return {
session,
csrfToken: created.csrfToken
};
}
return {
session,
csrfToken: session.csrfToken
};
}
function sendJson(response, statusCode, data) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
response.end(JSON.stringify(data));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
reject(new Error("Request body is too large"));
request.destroy();
}
});
request.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Invalid JSON"));
}
});
request.on("error", reject);
});
}
function isAllowedOrigin(request) {
const origin = request.headers.origin;
if (origin) {
return origin === ORIGIN;
}
const referer = request.headers.referer;
if (referer) {
try {
return new URL(referer).origin === ORIGIN;
} catch {
return false;
}
}
// Для критичних операцій запит без даних про походження відхиляємо.
return false;
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, ORIGIN);
if (request.method === "GET" && url.pathname === "/") {
const { session, csrfToken } = getSession(request, response);
response.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store"
});
response.end(`<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>CSRF demo</title>
</head>
<body>
<h1>Профіль</h1>
<p>Поточне ім’я: <strong id="current-name">${session.displayName}</strong></p>
<form id="profile-form">
<label>
Нове ім’я:
<input id="display-name" name="displayName" required>
</label>
<button type="submit">Зберегти</button>
</form>
<p id="message"></p>
<script>
const csrfToken = ${JSON.stringify(csrfToken)};
const form = document.querySelector("#profile-form");
const message = document.querySelector("#message");
const currentName = document.querySelector("#current-name");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const displayName = document.querySelector("#display-name").value;
const response = await fetch("/api/profile", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ displayName })
});
const result = await response.json();
if (!response.ok) {
message.textContent = result.error || "Не вдалося зберегти зміни";
return;
}
currentName.textContent = result.displayName;
message.textContent = "Зміни збережено";
});
</script>
</body>
</html>`);
return;
}
if (request.method === "POST" && url.pathname === "/api/profile") {
const cookies = parseCookies(request);
const session = sessions.get(cookies.session);
if (!session) {
sendJson(response, 401, { error: "Потрібна автентифікація" });
return;
}
if (!isAllowedOrigin(request)) {
sendJson(response, 403, { error: "Заборонене походження запиту" });
return;
}
const receivedToken = request.headers["x-csrf-token"];
if (
typeof receivedToken !== "string" ||
receivedToken.length !== session.csrfToken.length ||
!crypto.timingSafeEqual(
Buffer.from(receivedToken),
Buffer.from(session.csrfToken)
)
) {
sendJson(response, 403, { error: "Недійсний CSRF-токен" });
return;
}
try {
const body = await readJson(request);
if (
typeof body.displayName !== "string" ||
body.displayName.trim().length < 2 ||
body.displayName.trim().length > 50
) {
sendJson(response, 400, { error: "Некоректне ім’я" });
return;
}
session.displayName = body.displayName.trim();
sendJson(response, 200, {
displayName: session.displayName
});
} catch {
sendJson(response, 400, { error: "Некоректне тіло запиту" });
}
return;
}
sendJson(response, 404, { error: "Не знайдено" });
});
server.listen(PORT, () => {
console.log(`Сервер запущено: ${ORIGIN}`);
});У реальному застосунку:
сесії зберігають у базі даних або спеціальному сховищі;
використовують HTTPS;
cookie позначають Secure;
додають Content-Security-Policy;
перевіряють права користувача для кожної операції;
застосовують коректну обробку помилок і журналювання.
У демонстраційному прикладі Secure не встановлено, оскільки локальний сервер працює через http://localhost. У production-середовищі сесійна cookie має мати такий вигляд:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/CORS визначає, чи може JavaScript одного походження прочитати відповідь іншого походження.
CSRF-захист визначає, чи має запит право змінити стан користувача.
Наприклад, сервер може дозволити:
Access-Control-Allow-Origin: https://app.exampleале це не замінює CSRF-токен. CORS потрібно налаштовувати для контролю доступу до відповідей, а CSRF-захист — для перевірки легітимності небезпечних запитів.
Не використовуйте таку конфігурацію разом із cookie:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueПід час використання credentials сервер має вказувати конкретні дозволені походження, а не *.
XSS-уразливість часто обходить CSRF-захист, оскільки шкідливий JavaScript виконується вже всередині довіреного походження.
Такий скрипт може:
прочитати CSRF-токен зі сторінки;
виконати fetch із правильними cookie;
відправити запит із правильним заголовком;
прочитати відповідь сервера.
Тому CSRF-захист не замінює:
екранування даних під час вставки в HTML;
безпечну роботу з innerHTML;
Content Security Policy;
валідацію та санітизацію введення;
захист від XSS у сторонніх бібліотеках.
SameSite значно зменшує ризик, але не скасовує потребу в токенах для критичних операцій.
Використовуйте кілька шарів захисту:
SameSite=Lax або Strict;
CSRF-токен;
перевірку Origin;
за потреби Fetch Metadata.
Наявність сесійної cookie означає лише, що браузер має сесію. Вона не доводить, що запит створила ваша сторінка.
Небезпечна перевірка:
if (request.cookies.session) {
executeTransfer();
}Потрібно перевіряти також токен, походження та права користувача.
Якщо сервер читає токен лише з cookie, браузер автоматично додасть його і до підробленого запиту. Токен потрібно передавати ще одним каналом — у заголовку або тілі запиту.
GETGET не повинен змінювати стан. Якщо endpoint із GET все ж виконує небезпечну дію, він залишається вразливим до простих атак через img, iframe або посилання.
Небезпечно перевіряти лише частину рядка:
origin.endsWith("example.com")Безпечніше порівнювати повне значення:
origin === "https://app.example.com"Відсутній токен — це помилка, а не причина пропустити перевірку.
if (!receivedToken || receivedToken !== expectedToken) {
return forbidden();
}URL можуть потрапляти в історію, логи та заголовки Referer. Використовуйте заголовок або тіло запиту.
HttpOnly забороняє JavaScript прочитати cookie, але не забороняє браузеру автоматично надіслати її. Це захист від крадіжки cookie через JavaScript, а не від CSRF.
Для endpoint, який змінює стан:
Використовуйте метод POST, PUT, PATCH або DELETE.
Застосуйте сесійну cookie з HttpOnly, Secure і SameSite=Lax або Strict.
Згенеруйте криптографічно випадковий CSRF-токен.
Прив’яжіть токен до сесії.
Передавайте токен у заголовку або тілі запиту.
Перевіряйте токен на сервері.
Перевіряйте точне значення Origin.
За потреби перевіряйте Referer і Fetch Metadata.
Перевіряйте автентифікацію та авторизацію.
Не покладайтеся лише на клієнтський JavaScript: усі перевірки мають виконуватися на сервері.
CSRF змушує браузер автентифікованого користувача виконати небажану дію.
Автоматичне надсилання cookie є головною передумовою CSRF.
SameSite зменшує кількість міжсайтових запитів із cookie.
CSRF-токен доводить, що запит походить із довіреної сторінки.
Origin потрібно порівнювати з точним дозволеним походженням.
Referer і Fetch Metadata корисні як додаткові перевірки.
CORS і CSRF вирішують різні задачі.
XSS може обійти CSRF-захист, тому потрібен комплексний захист браузерного застосунку.