Пошук уроків, статей та іншого контенту
Налаштуєте cookies, credentials і CORS для безпечної взаємодії браузера з різними джерелами.
Під час взаємодії браузера з сервером важливо розрізняти три поняття:
origin — комбінація протоколу, домену та порту;
cookie — невеликий фрагмент даних, який браузер зберігає для домену;
CORS — механізм, що визначає, чи може JavaScript прочитати відповідь від іншого origin.
Наприклад:
https://app.example.com
https://api.example.com
http://localhost:3000
http://localhost:4000
Перші два URL мають різні origin через різні host, а останні два — через різні порти.
Водночас origin і site — не те саме. Домени app.example.com та api.example.com можуть бути різними origin, але належати до одного site. Це впливає, зокрема, на поведінку SameSite для cookies.
Cookie надсилається сервером через заголовок Set-Cookie:
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=LaxПісля цього браузер зберігає cookie і додає його до відповідних HTTP-запитів:
Cookie: sessionId=abc123HttpOnlySet-Cookie: sessionId=abc123; HttpOnlyCookie з HttpOnly:
надсилається браузером автоматично;
недоступна через document.cookie.
Це захищає значення cookie від прямого читання JavaScript-кодом у разі XSS-атаки. Однак HttpOnly не забороняє браузеру надсилати cookie під час запиту, створеного шкідливим скриптом.
SecureSet-Cookie: sessionId=abc123; SecureCookie з Secure надсилається лише через HTTPS. Для локальної розробки браузери зазвичай вважають localhost безпечним контекстом, але в реальному середовищі потрібно використовувати HTTPS.
SameSiteВизначає, коли cookie можна надсилати в контексті запитів з іншого site.
Можливі значення:
Strict — cookie надсилається лише в максимально обмежених same-site сценаріях;
Lax — безпечні переходи верхнього рівня зазвичай дозволені, а багато крос-сайтових підзапитів блокуються;
None — cookie можна надсилати у крос-сайтових контекстах.
Для SameSite=None обов’язково потрібен Secure:
Set-Cookie: sessionId=abc123; SameSite=None; SecureСучасні браузери також можуть блокувати сторонні cookies незалежно від налаштувань сайту або через налаштування приватності користувача.
PathОбмежує шляхи, для яких cookie надсилається:
Set-Cookie: theme=dark; Path=/Cookie буде доступна для всіх шляхів цього домену.
DomainВизначає домен, для якого призначена cookie:
Set-Cookie: sessionId=abc123; Domain=example.comТаку cookie можуть отримувати відповідні піддомени, якщо правила браузера це дозволяють. Не варто вказувати ширший Domain, ніж потрібно застосунку.
Cookies без HttpOnly можна прочитати через document.cookie:
console.log(document.cookie);
// "theme=dark; language=uk"document.cookie повертає лише cookies поточного документа. JavaScript не може прочитати cookies іншого домену або cookies з атрибутом HttpOnly.
Для сесійних ідентифікаторів зазвичай краще використовувати:
Set-Cookie: sessionId=random-value; HttpOnly; Secure; SameSite=LaxТак JavaScript не отримує безпосереднього доступу до токена сесії.
credentials у Fetch APIПараметр credentials визначає, чи може fetch працювати з cookies та іншими обліковими даними.
fetch("/api/profile", {
credentials: "same-origin"
});Можливі значення:
"omit" — не надсилати credentials і не враховувати Set-Cookie у відповіді;
"same-origin" — використовувати credentials лише для same-origin-запитів;
"include" — використовувати credentials також для крос-оригінальних запитів.
Значення за замовчуванням — "same-origin".
Для запиту до іншого origin зазвичай потрібно:
const response = await fetch("https://api.example.com/profile", {
credentials: "include"
});Цей параметр не вимикає правила cookies. Браузер усе одно враховує:
Domain;
Path;
Secure;
SameSite;
політику блокування сторонніх cookies;
інші правила безпеки браузера.
credentials: "include" лише дозволяє браузеру використовувати credentials у такому запиті, якщо інші умови виконані.
Якщо сервер встановлює cookie у відповіді на крос-оригінальний запит, клієнт також має використовувати:
fetch("https://api.example.com/login", {
method: "POST",
credentials: "include"
});Значення Set-Cookie не можна прочитати через JavaScript:
const response = await fetch("https://api.example.com/login", {
credentials: "include"
});
console.log(response.headers.get("Set-Cookie"));
// nullЦе спеціальне обмеження браузера. Cookie може бути збережена, але заголовок Set-Cookie не стає доступним скрипту.
CORS, або Cross-Origin Resource Sharing, — це механізм браузера, який контролює доступ JavaScript до відповідей від іншого origin.
Наприклад, сторінка з:
http://localhost:3000виконує запит до:
http://localhost:4000Це крос-оригінальний запит. Сервер може дозволити його заголовком:
Access-Control-Allow-Origin: http://localhost:3000Якщо заголовок відсутній або має неправильне значення, браузер заблокує доступ JavaScript до відповіді. Сам сервер при цьому міг успішно виконати запит.
Важливо: CORS — це захист браузера. Він не є механізмом автентифікації і не забороняє клієнту поза браузером відправити HTTP-запит до сервера.
Клієнт:
const response = await fetch("http://localhost:4000/api/message");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
console.log(data.message);Сервер має повернути:
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/jsonСервер не повинен бездумно повертати значення Origin без перевірки. Дозволені origins потрібно перевіряти за списком.
Для крос-оригінального запиту з cookies потрібні всі умови:
клієнт використовує credentials: "include";
сервер повертає конкретний Access-Control-Allow-Origin;
сервер повертає Access-Control-Allow-Credentials: true;
cookie дозволяє такий контекст через свої атрибути;
браузер не блокує сторонні cookies власною політикою.
Заголовки відповіді:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: trueПри використанні credentials не можна використовувати wildcard:
Access-Control-Allow-Origin: *Це некоректна комбінація:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueЯкщо сервер динамічно вибирає дозволений origin, варто додати:
Vary: OriginЦе повідомляє кешам, що відповідь залежить від заголовка Origin.
Перед деякими крос-оригінальними запитами браузер відправляє попередній запит методом OPTIONS. Він називається preflight.
Preflight може виникнути, якщо запит:
використовує метод PUT, PATCH або DELETE;
містить нестандартні заголовки;
використовує тип вмісту, який не належить до простих типів, наприклад application/json у деяких сценаріях.
Приклад клієнта:
const response = await fetch("http://localhost:4000/api/profile", {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-Client-Version": "1"
},
body: JSON.stringify({
displayName: "Олена"
})
});Браузер спочатку може відправити приблизно такий запит:
OPTIONS /api/profile HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: content-type,x-client-versionСервер повинен відповісти дозволами:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: PATCH, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Client-VersionЯкщо preflight не отримує потрібних дозволів, основний PATCH-запит браузер не виконає.
У цьому прикладі:
API працює на http://localhost:4000;
клієнт працює на http://localhost:3000;
API встановлює HttpOnly cookie;
клієнт надсилає cookie за допомогою credentials: "include";
сервер налаштовує CORS без wildcard.
Створіть файл server.js:
const http = require("node:http");
const allowedOrigin = "http://localhost:3000";
function sendJson(response, statusCode, data, extraHeaders = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
...extraHeaders
});
response.end(JSON.stringify(data));
}
function getCookies(request) {
const header = request.headers.cookie || "";
return Object.fromEntries(
header
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) {
return [part, ""];
}
const name = part.slice(0, separatorIndex);
const value = part.slice(separatorIndex + 1);
return [name, decodeURIComponent(value)];
})
);
}
const server = http.createServer((request, response) => {
const origin = request.headers.origin;
if (origin === allowedOrigin) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Vary", "Origin");
}
if (request.method === "OPTIONS") {
response.writeHead(204, {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Vary": "Origin"
});
response.end();
return;
}
if (request.method === "POST" && request.url === "/login") {
sendJson(
response,
200,
{ message: "Вхід виконано" },
{
"Set-Cookie":
"sessionId=demo-session; Path=/; HttpOnly; SameSite=Lax"
}
);
return;
}
if (request.method === "GET" && request.url === "/me") {
const cookies = getCookies(request);
if (cookies.sessionId !== "demo-session") {
sendJson(response, 401, { message: "Потрібна автентифікація" });
return;
}
sendJson(response, 200, {
id: 42,
name: "Олена",
role: "developer"
});
return;
}
sendJson(response, 404, { message: "Маршрут не знайдено" });
});
server.listen(4000, () => {
console.log("API працює на http://localhost:4000");
});Запустіть API:
node server.jsСтворіть файл index.html:
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>CORS і cookies</title>
</head>
<body>
<button id="login">Увійти</button>
<button id="load-profile">Завантажити профіль</button>
<pre id="output"></pre>
<script>
const output = document.querySelector("#output");
async function request(url, options = {}) {
const response = await fetch(`http://localhost:4000${url}`, {
...options,
credentials: "include"
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
}
document.querySelector("#login").addEventListener("click", async () => {
try {
const data = await request("/login", {
method: "POST"
});
output.textContent = data.message;
} catch (error) {
output.textContent = error.message;
}
});
document
.querySelector("#load-profile")
.addEventListener("click", async () => {
try {
const profile = await request("/me");
output.textContent = JSON.stringify(profile, null, 2);
} catch (error) {
output.textContent = error.message;
}
});
</script>
</body>
</html>В іншому терміналі запустіть статичний сервер у директорії з index.html:
python3 -m http.server 3000Відкрийте http://localhost:3000. Після натискання «Увійти» сервер встановить cookie. Після натискання «Завантажити профіль» браузер надішле цю cookie до API.
Порти 3000 і 4000 роблять запити крос-оригінальними, тому CORS потрібен. Водночас localhost:3000 і localhost:4000 зазвичай вважаються одним site для правил SameSite. Для повноцінного тестування саме крос-сайтових cookies потрібні різні site та, як правило, HTTPS.
Cookie надсилається браузером автоматично. Через це зловмисний сайт може спробувати змусити браузер жертви виконати запит до вашого сервера.
Це називається CSRF, або Cross-Site Request Forgery.
CORS не є повним захистом від CSRF. Навіть якщо браузер не дозволяє шкідливому скрипту прочитати відповідь, сам запит іноді може бути відправлений.
Захист зазвичай складається з кількох рівнів:
використовувати SameSite=Lax або SameSite=Strict, якщо це сумісно з архітектурою;
для небезпечних операцій застосовувати CSRF-токен;
перевіряти Origin або Referer на сервері, коли це доречно;
не змінювати стан через GET;
перевіряти автентифікацію та авторизацію на сервері.
CSRF-токен повинен бути непередбачуваним і перевірятися сервером. Перевірка лише на стороні JavaScript не захищає API.
За замовчуванням JavaScript може прочитати лише обмежений набір заголовків відповіді. Якщо потрібно відкрити власний заголовок, сервер має використати Access-Control-Expose-Headers.
Наприклад:
Access-Control-Expose-Headers: X-Request-Id
X-Request-Id: request-123Тоді клієнт зможе виконати:
const response = await fetch("http://localhost:4000/api/data");
console.log(response.headers.get("X-Request-Id"));Без Access-Control-Expose-Headers запит може бути успішним, але значення X-Request-Id буде недоступним JavaScript.
Безпечна політика зазвичай має такі властивості:
дозволені origins задаються явним списком;
для credentials використовується конкретний origin;
дозволяються лише потрібні HTTP-методи;
дозволяються лише необхідні заголовки;
preflight обробляється окремо;
відповідь із динамічним Origin має Vary: Origin.
Наприклад, замість безумовного дозволу всім:
Access-Control-Allow-Origin: *краще перевіряти значення Origin:
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
const origin = request.headers.origin;
if (allowedOrigins.has(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Vary", "Origin");
}Самого заголовка CORS недостатньо для захисту даних. Сервер також повинен перевіряти сесію, права користувача і коректність запиту.
credentials: "include" без серверної підтримкиfetch("https://api.example.com/data", {
credentials: "include"
});Якщо сервер не повертає Access-Control-Allow-Credentials: true, браузер не надасть JavaScript доступ до відповіді.
* разом із credentialsAccess-Control-Allow-Origin: *
Access-Control-Allow-Credentials: trueДля credentialed-запитів потрібно вказати конкретний origin.
CORS не визначає, хто має право отримати дані. Він лише контролює доступ браузерного JavaScript до відповіді. Автентифікація та авторизація мають виконуватися сервером.
Set-Cookieresponse.headers.get("Set-Cookie");Цей заголовок недоступний JavaScript. Перевіряти потрібно не заголовок, а результат наступного запиту, який використовує cookie.
credentials під час логінуЯкщо логін виконується на іншому origin і в запиті немає credentials: "include", браузер може не зберегти cookie з відповіді для подальшого використання.
SameSiteCookie з SameSite=Lax або SameSite=Strict не обов’язково буде надіслана в крос-сайтовому сценарії. Якщо справді потрібна стороння cookie, може знадобитися:
SameSite=None; SecureАле це послаблює обмеження, тому потрібно додатково продумати CSRF-захист.
OPTIONSКлієнтський PATCH, DELETE, JSON-запит або запит із власним заголовком може спочатку отримати preflight. Якщо сервер не обробляє OPTIONS, основний запит не відбудеться.
Дозвіл усіх origins, методів і заголовків збільшує поверхню атаки. Політику CORS слід обмежувати фактичними потребами застосунку.
origin складається з протоколу, host і порту.
CORS визначає, чи може JavaScript прочитати крос-оригінальну відповідь.
Cookies зберігаються і надсилаються браузером автоматично за правилами Domain, Path, Secure та SameSite.
Для крос-оригінальних cookies у fetch потрібен credentials: "include".
Credentialed CORS потребує конкретного Access-Control-Allow-Origin і Access-Control-Allow-Credentials: true.
Access-Control-Allow-Origin: * не сумісний із credentials.
Складні крос-оригінальні запити проходять preflight через метод OPTIONS.
CORS не замінює автентифікацію, авторизацію та захист від CSRF.
Для сесійних cookies зазвичай варто розглянути HttpOnly, Secure і відповідне значення SameSite.