Пошук уроків, статей та іншого контенту
Пояснимо призначення CORS і CSRF та налаштуємо захист API від небезпечних міжсайтових запитів.
CORS (Cross-Origin Resource Sharing) — це механізм браузера, який визначає, чи може вебсторінка з одного origin читати відповіді сервера з іншого origin.
Origin складається з:
протоколу;
домену;
порту.
Наприклад, ці адреси мають різні origin:
http://localhost:3000
http://localhost:5173
https://example.com
Навіть якщо два застосунки працюють на одному комп’ютері, різні порти роблять їх різними origin.
Браузер може відправити запит на інший origin, але заборонити JavaScript прочитати відповідь, якщо сервер не дозволив такий обмін через CORS-заголовки.
Наприклад, сервер може відповісти:
Access-Control-Allow-Origin: http://localhost:5173Це означає: відповідь дозволено читати сторінці, завантаженій з http://localhost:5173.
CORS — це правило для браузерів. Він не забороняє запити, які надсилають:
curl;
Postman;
інший сервер;
мобільний застосунок.
Тому CORS не замінює автентифікацію, авторизацію або перевірку вхідних даних.
Його завдання — не дозволити довільному сайту прочитати відповідь вашого API у браузері.
Браузер може виконати CORS-запит двома способами.
Деякі запити браузер надсилає одразу. Наприклад:
fetch('https://api.example.com/products')Сервер має додати до відповіді відповідний Access-Control-Allow-Origin.
Якщо запит потенційно небезпечний або містить нестандартні заголовки, браузер спочатку надсилає OPTIONS-запит.
Наприклад, такий код зазвичай спричинить preflight-запит:
fetch('https://api.example.com/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'token'
},
body: JSON.stringify({ name: 'Oksana' })
})Браузер спочатку може надіслати:
OPTIONS /profile HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-csrf-tokenСервер повинен явно підтвердити цей запит:
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-CSRF-TokenЯкщо сервер не підтвердить preflight, браузер не виконає основний запит.
Cookies браузер не передає в cross-origin fetch автоматично. Для цього потрібно вказати:
fetch('http://localhost:3000/api/profile', {
credentials: 'include'
})Сервер у такому випадку повинен повернути:
Access-Control-Allow-Credentials: trueТакож Access-Control-Allow-Origin не може мати значення *.
Неправильно:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueПравильно:
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: trueСервер повинен дозволяти лише відомі origin, а не повертати значення Origin без перевірки.
CSRF (Cross-Site Request Forgery) — це атака, під час якої сторонній сайт змушує браузер користувача виконати дію в іншому застосунку.
Уявімо, що користувач увійшов до bank.example.com, а браузер зберіг сесійну cookie:
session_id=abc123Потім користувач відкрив шкідливий сайт. Цей сайт може спробувати виконати запит:
<form action="https://bank.example.com/api/transfer" method="POST">
<input name="amount" value="1000">
<input name="to" value="attacker">
</form>
<script>
document.forms[0].submit()
</script>Браузер може автоматично додати cookie session_id=abc123 до запиту. Сервер побачить чинну сесію і, якщо не має додаткового захисту, може виконати операцію.
Важливо: шкідливий сайт не обов’язково може прочитати відповідь. Для CSRF достатньо, що небезпечна дія буде виконана.
CORS переважно контролює, чи може JavaScript прочитати відповідь.
CSRF використовує іншу властивість браузера — автоматичне додавання cookies до запиту.
Тому навіть якщо CORS налаштований правильно, небезпечний запит може бути відправлений, наприклад через HTML-форму. Захист від CSRF потрібно реалізовувати окремо.
Сервер генерує випадковий токен і передає його довіреному клієнту. Клієнт додає токен до кожного небезпечного запиту:
X-CSRF-Token: випадкове-значенняСервер порівнює цей заголовок із токеном, який очікується для поточної сесії.
Сторонній сайт може спробувати відправити запит, але не може прочитати токен із вашого origin і тому не здатен сформувати правильний заголовок.
Для запитів, які змінюють дані, зазвичай перевіряють CSRF-токен у:
POST;
PUT;
PATCH;
DELETE.
Для GET не повинно бути побічних ефектів.
OriginСервер може перевіряти заголовок Origin і приймати небезпечні запити лише від власного клієнта:
Origin: http://localhost:5173Це корисний додатковий рівень захисту, але не варто покладатися лише на нього. CSRF-токен і перевірка origin доповнюють один одного.
SameSite для cookieCookie може мати атрибут:
Set-Cookie: session_id=abc123; HttpOnly; SameSite=LaxЗначення SameSite:
Strict — cookie майже не передається в міжсайтових сценаріях;
Lax — дозволяє обмежений набір безпечних переходів;
None — дозволяє міжсайтову передачу, але потребує Secure.
SameSite зменшує ризик CSRF, але додатковий CSRF-захист все одно потрібен для важливих операцій.
Нижче наведено приклад сервера без сторонніх пакетів. Він:
дозволяє CORS лише для http://localhost:5173;
обробляє preflight-запити;
дозволяє cookies через credentials;
генерує CSRF-токен;
перевіряє Origin;
перевіряє заголовок X-CSRF-Token перед зміною даних.
Збережіть код у файлі server.js і запустіть командою node server.js.
const http = require('node:http');
const crypto = require('node:crypto');
const PORT = 3000;
const ALLOWED_ORIGIN = 'http://localhost:5173';
function parseCookies(request) {
const header = request.headers.cookie || '';
return Object.fromEntries(
header
.split(';')
.filter(Boolean)
.map((part) => {
const index = part.indexOf('=');
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
return [key, decodeURIComponent(value)];
})
);
}
function sendJson(response, statusCode, data, headers = {}) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
...headers
});
response.end(JSON.stringify(data));
}
function setCorsHeaders(response, origin) {
if (origin === ALLOWED_ORIGIN) {
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');
response.setHeader('Vary', 'Origin');
}
}
function readJsonBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) {
reject(new Error('Тіло запиту занадто велике'));
request.destroy();
}
});
request.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error('Некоректний JSON'));
}
});
request.on('error', reject);
});
}
const server = http.createServer(async (request, response) => {
const origin = request.headers.origin;
setCorsHeaders(response, origin);
if (request.method === 'OPTIONS') {
if (origin !== ALLOWED_ORIGIN) {
return sendJson(response, 403, {
error: 'Origin не дозволено'
});
}
response.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS'
);
response.setHeader(
'Access-Control-Allow-Headers',
'Content-Type, X-CSRF-Token'
);
response.writeHead(204);
return response.end();
}
if (request.url === '/api/csrf' && request.method === 'GET') {
const token = crypto.randomBytes(32).toString('hex');
// Cookie доступна JavaScript, щоб клієнт міг порівняти її зі значенням у заголовку.
response.setHeader(
'Set-Cookie',
`csrf_token=${encodeURIComponent(token)}; Path=/; SameSite=Lax`
);
return sendJson(response, 200, { csrfToken: token });
}
if (request.url === '/api/profile' && request.method === 'POST') {
if (origin !== ALLOWED_ORIGIN) {
return sendJson(response, 403, {
error: 'Запит має неприпустимий Origin'
});
}
const cookies = parseCookies(request);
const csrfCookie = cookies.csrf_token;
const csrfHeader = request.headers['x-csrf-token'];
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return sendJson(response, 403, {
error: 'Некоректний CSRF-токен'
});
}
try {
const body = await readJsonBody(request);
if (typeof body.name !== 'string' || body.name.trim() === '') {
return sendJson(response, 400, {
error: 'Поле name є обов’язковим'
});
}
return sendJson(response, 200, {
message: 'Профіль оновлено',
name: body.name.trim()
});
} catch (error) {
return sendJson(response, 400, {
error: error.message
});
}
}
return sendJson(response, 404, {
error: 'Маршрут не знайдено'
});
});
server.listen(PORT, () => {
console.log(`API працює на http://localhost:${PORT}`);
});Довірений клієнт спочатку отримує CSRF-токен, а потім використовує його під час зміни даних:
const csrfResponse = await fetch('http://localhost:3000/api/csrf', {
credentials: 'include'
});
const { csrfToken } = await csrfResponse.json();
const updateResponse = await fetch('http://localhost:3000/api/profile', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
name: 'Олена'
})
});
const result = await updateResponse.json();
console.log(result);Оскільки запит містить Content-Type: application/json і X-CSRF-Token, браузер перед ним виконає preflight. Сервер дозволяє потрібний метод і заголовки, після чого основний запит проходить перевірку.
У реальному застосунку сесійна cookie повинна мати щонайменше такі атрибути:
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/HttpOnly не дозволяє JavaScript прочитати сесійну cookie;
Secure передає cookie лише через HTTPS;
SameSite=Lax обмежує міжсайтову передачу;
Path=/ робить cookie доступною для всього застосунку.
CSRF-cookie в наведеному прикладі не має HttpOnly, тому що клієнт читає токен і передає його в заголовку. Це не означає, що токен захищає від XSS. Якщо зловмисний JavaScript уже виконується у вашому origin, він може прочитати токен і діяти від імені користувача. Захист від XSS є окремим завданням.
У production-застосунку CSRF-токен також варто пов’язувати із серверною сесією або використовувати підписаний double-submit token, а не лише порівнювати довільне значення cookie із заголовком.
Під час налаштування CORS потрібно:
Створити явний список дозволених origin.
Не використовувати * разом із cookies.
Дозволяти лише необхідні HTTP-методи.
Дозволяти лише необхідні заголовки.
Обробляти OPTIONS для preflight-запитів.
Додавати Vary: Origin, якщо відповідь залежить від значення Origin.
Не вважати CORS заміною автентифікації та авторизації.
Наприклад, дозволяти всі origin небезпечно:
response.setHeader('Access-Control-Allow-Origin', origin);Якщо origin не перевіряється, будь-який сайт може отримати дозвіл на читання відповіді.
Краще:
const allowedOrigins = new Set([
'https://app.example.com',
'https://admin.example.com'
]);
if (allowedOrigins.has(origin)) {
response.setHeader('Access-Control-Allow-Origin', origin);
}Access-Control-Allow-Origin: * для приватного APIЦе відкриває читання відповідей для будь-якого origin. Для API з cookie потрібно вказувати конкретний origin.
CORS не блокує запити від curl і не є заміною перевірки прав доступу. Кожен endpoint повинен сам перевіряти автентифікацію та авторизацію.
POSTЯкщо операція змінює дані, session cookie може бути автоматично додана браузером. Потрібні CSRF-токен, перевірка Origin і відповідні налаштування cookies.
RefererЗаголовок Referer може бути відсутнім або скороченим політикою приватності. Для CSRF-перевірок частіше використовують Origin, а для критичних операцій — також CSRF-токен.
credentials: 'include'Без цього браузер не додасть cookies до cross-origin fetch, навіть якщо сервер дозволяє credentials.
Налаштування на кшталт Access-Control-Allow-Methods: * або надто широкий список заголовків ускладнює контроль і збільшує площу атаки. Дозволяйте тільки те, що реально використовує клієнт.
CORS контролює, чи може браузер прочитати відповідь cross-origin API.
CORS не захищає API від запитів із серверів, curl або Postman.
CSRF використовує автоматичне надсилання cookies браузером.
Для захисту від CSRF застосовують CSRF-токени, перевірку Origin і SameSite для cookies.
Для credentialed CORS потрібно використовувати credentials: 'include' і конкретний Access-Control-Allow-Origin.
Небезпечні методи мають перевіряти origin, CSRF-токен, автентифікацію та права доступу.