Покупатель входит в магазин раз в месяц, а его сессия живёт в браузере дольше, чем путь от главной страницы до кнопки оплаты. За этой невидимой частью интерфейса стоит контракт: сервер хранит запись о посетителе, браузер носит идентификатор в cookie, и каждый запрос предъявляет его автоматически. У контракта есть враги: угон идентификатора, фиксация сессии на заранее известном значении и подделка запроса, которую браузер выполняет сам, не спрашивая владельца. Статья разбирает защиту сессии на примере Node.js и Express: генерацию идентификаторов, механику CSRF, токены в заголовках и формах, атрибуты cookie, ротацию после входа и разделение access и refresh токенов на библиотеке jose версии 6.2.13.

Анатомия сессии на cookie и цена утечки идентификатора

HTTP не помнит посетителя между запросами, поэтому сессию собирают вручную: сервер кладёт запись в хранилище, а браузеру отдаёт уникальный идентификатор. Дальше cookie ездит с каждым обращением к домену сама, и сервер по значению находит корзину, избранное и статус входа. Слабое место ровно одно, но фатальное: любой, кто узнал идентификатор, становится владельцем сессии, а серверу неоткуда взять сомнение.

Отсюда требования к идентификатору: длина, случайность, непредсказуемость. Отраслевые руководства по веб-безопасности требуют минимум 64 бита энтропии и рекомендуют 128 и выше. На практике берут 32 случайных байта, то есть 256 бит, и записывают их шестнадцатеричной строкой:

import crypto from "node:crypto";

const sessionId = crypto.randomBytes(32).toString("hex");
console.log(sessionId.length);

В строке выходит 64 знака. Генератор подключается к express-session версии 1.19.0 напрямую через параметр genid:

import express from "express";
import session from "express-session";

const app = express();

app.use(
  session({
    name: "sid",
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    genid: () => crypto.randomBytes(32).toString("hex"),
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 30 * 60 * 1000
    }
  })
);

Каждый параметр здесь отвечает за конкретный риск. Имя sid не выдаёт библиотеку: стандартное connect.js-имя выдаёт стек с первого взгляда и упрощает разведку. Параметр saveUninitialized: false запрещает создавать записи для анонимных запросов без данных, иначе сервер плодил бы пустые сессии на каждое обращение ботов. Параметр resave: false не переписывает запись без изменений. Возраст 30 минут заставляет сессию устаревать сама собой, даже если посетитель ушёл, не нажав выход. Встроенное хранилище MemoryStore годится только для разработки, в рабочем окружении запись сессии переезжает в Redis или похожее внешнее хранилище через готовый адаптер, и перезапуск процесса перестаёт разлогинивать всех сразу.

Механика атаки CSRF на примере смены email в личном кабинете

Подделка межсайтового запроса опирается на привычку браузера: cookie прикладывается к каждому обращению к домену автоматически, без разрешения текущей страницы. Представим магазин, где смена почты в профиле принимается простым POST без лишних проверок. Злоумышленник размещает на любом своём домене страницу с формой:

<!-- код на чужом домене, action ведёт в магазин -->
<form action="shop.example/profile/email" method="post">
  <input type="hidden" name="email" value="Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.">
  <input type="submit" hidden>
</form>
<script>document.forms[0].submit();</script>

Залогиненный покупатель открывает злополучную страницу, форма отправляется сама, cookie магазина прикладывается к запросу, и сервер послушно меняет адрес восстановления пароля. Подменённая почта открывает путь к сбросу пароля, а жертва не нажимала ни одной кнопки в магазине. Сервер не отличит подлинный запрос от поддельного: оба пришли с действующей cookie из браузера жертвы.

Отдельная заметка про GET: операции с изменениями должны ходить только методами POST, PUT, PATCH и DELETE. Ссылка на смену почты через GET означала бы атаку одной картинкой, подгруженной с чужой страницы, и рисовать форму не пришлось бы вовсе.

Синхронный токен и двойная отправка cookie с безопасным сравнением

Арсенал защиты от CSRF складывается из трёх независимых слоёв:

  1. проверка заголовков Origin и Referer как быстрая первая линия отбора;
  2. обязательный секретный токен в скрытом поле формы или в кастомном заголовке запроса;
  3. атрибут SameSite у сессионной cookie как последний рубеж на стороне браузера.

Первый способ хранения токена - синхронная схема. Сервер генерирует случайное значение, кладёт его в запись сессии и в скрытое поле формы, а при приёме запроса сверяет пару. Схема надёжна, но требует хранения состояния на сервере. Второй способ - двойная отправка: токен кладётся в cookie и дублируется в заголовке того же запроса, серверу достаточно сравнить оба значения, ничего не запоминая:

import cookieParser from "cookie-parser";
import crypto from "node:crypto";

app.use(cookieParser());

const CSRF_COOKIE = "csrf-token";

function issueToken(res) {
  const token = crypto.randomBytes(32).toString("hex");
  res.cookie(CSRF_COOKIE, token, {
    secure: true,
    sameSite: "strict",
    maxAge: 30 * 60 * 1000
  });
  return token;
}

function verifyToken(req, res, next) {
  const cookieToken = req.cookies[CSRF_COOKIE] ?? "";
  const headerToken = req.get("x-csrf-token") ?? "";
  const a = Buffer.from(cookieToken);
  const b = Buffer.from(headerToken);

  if (a.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)) {
    next();
  } else {
    res.status(403).end("csrf token invalid");
  }
}

Cookie с токеном специально оставлена без HttpOnly: фронтенд обязан прочитать значение и продублировать его в заголовке, и это безопасно, потому что сам токен без сопутствующей сессии ничего не открывает. Сравнение выполняется функцией timingSafeEqual в постоянном времени, без раннего выхода на первом несовпадении, иначе злоумышленник восстановил бы секрет по разнице в скорости ответов. Одинаковую длину проверяют до сравнения, потому что функция с буферами разного размера падает.

Фронтенд прикладывает токен к каждому изменяющему запросу:

async function saveEmail(email) {
  await fetch("/profile/email", {
    method: "post",
    credentials: "same-origin",
    headers: {
      "content-type": "application/json",
      "x-csrf-token": readCookie("csrf-token")
    },
    body: JSON.stringify({ email })
  });
}

Атака на такой схеме ломается по двум линиям сразу. Чужая страница способна отправить форму, но не способна поставить кастомный заголовок в простом запросе. Скрипт с чужого домена не прочитает cookie магазина: same-origin-политика браузера запрещает доступ к чужим доменам, а запрос с заголовком упрётся в проверку CORS, разрешения для которой честный магазин не выдаёт посторонним. Готовая реализация двойной отправки с подписями живёт в пакете csrf версии 3.1.0.

Атрибуты HttpOnly Secure SameSite и префиксы для доверенных cookie

У сессионной cookie есть набор атрибутов, и каждый закрывает отдельный сценарий кражи. HttpOnly прячет значение от JavaScript страницы: внедрённый скрипт не унесёт идентификатор в чужой запрос. Secure разрешает передачу только по HTTPS и защищает cookie в открытых сетях. SameSite регулирует поведение при переходах с чужих доменов: Strict не отправляет cookie вовсе, Lax прикладывает её только в GET-навигации верхнего уровня, None разрешает всё, но требует Secure и осознанного решения. С февраля 2020 года Chrome выставляет Lax по умолчанию, если атрибут не указан, поэтому явная запись остаётся обязанностью, а не пожеланием:

res.cookie("sid", token, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  path: "/",
  maxAge: 1800000
});

У браузеров есть и префиксы имён для доверенных cookie. Cookie с именем, начинающимся на __Host-, принимается только при Secure, без атрибута Domain и с Path на корень, что отсекает подмену из поддомена. Префикс __Secure- требует хотя бы атрибут Secure. Проверку выполняет сам браузер, и подделать такие cookie из чужого контекста заметно труднее.

При этом SameSite - не панацея, и держать одну её нельзя. Lax пропускает GET-переходы верхнего уровня, и защита держится на дисциплине не менять данные GET-запросами. Часть старых браузеров и мобильных веб-оболочек игнорирует атрибут. Для легальных межсайтовых сценариев вроде платежных шлюзов атрибут выставляют в None, и токены становятся обязательными вдвойне. Слои защиты ценны именно вместе.

Ротация идентификатора после входа и короткий срок жизни сессии

Фиксация сессии работает наоборот: не вор крадёт идентификатор, а злоумышленник заранее подкидывает жертве известное ему значение, например ссылкой с параметром sid, и после входа жертвы пользуется сессией сам. Первая защита - никогда не принимать идентификатор из URL, только из cookie. Вторая - менять идентификатор в момент роста прав:

app.post("/login", async (req, res) => {
  const user = await checkCredentials(req.body);
  if (!user) {
    return res.status(401).end();
  }

  req.session.regenerate((err) => {
    if (err) {
      return res.status(500).end();
    }
    req.session.userId = user.id;
    res.redirect("/cabinet");
  });
});

Регенерация в express-session создаёт новую запись с новым идентификатором и переносит в неё нужные данные, а подкинутое значение обесценивается мгновенно. Тот же принцип срабатывает на любом повышении привилегий: смена тарифа, вход в панель управления, подтверждение крупной операции. Выход делают настоящим, с уничтожением записи на сервере, а не одной очисткой cookie:

app.post("/logout", (req, res, next) => {
  req.session.destroy((err) => {
    if (err) {
      return next(err);
    }
    res.clearCookie("sid");
    res.redirect("/");
  });
});

Срок жизни строят из двух половин: скользящее продление при активности и абсолютный потолок от момента входа, после которого система переспрашивает пароль. Кнопка выхода со всех устройств инвалидирует не одну запись, а семейство сессий пользователя, что превращает украденный идентификатор в бесполезную строку одним щелчком.

Access и refresh токены для API и проверка подписи на jose

Когда за авторизацию отвечает не один сервер, а рой сервисов и мобильные клиенты, хранить сессии в общем хранилище становится тесно. Классический выход - короткоживущий access-токен и длинный refresh с ротацией. Access живёт минуты, подписывается секретом и проверяется без похода в хранилище. Библиотека jose версии 6.2.13 подписывает и проверяет за несколько строк:

import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const access = await new SignJWT({ sub: "user-42" })
  .setProtectedHeader({ alg: "HS256" })
  .setIssuedAt()
  .setExpirationTime("15m")
  .sign(secret);

const { payload } = await jwtVerify(access, secret);

Пятнадцать минут жизни access-токена означают: украденное значение годно четверть часа и не дольше. Refresh-токен живёт недели, но каждый его обмен на новую пару сопровождается ротацией: старый refresh признаётся использованным и попадает в стоп-лист. Если один и тот же refresh предъявляется дважды, система видит кражу и обнуляет всё семейство токенов пользователя.

Хранение токенов - отдельное решение. localStorage удобен при отладке и опасен в жизни: внедрённый скрипт читает его целиком, и защита от XSS остаётся единственной стеной. Refresh разумно держать в HttpOnly cookie с путём, ограниченным эндпоинтом обмена, а access - в памяти приложения. Для проектов, которым не нужен внешний сервер состояния, существует iron-session версии 9.0.1: пакет шифрует содержимое сессии и запечатывает его прямо в cookie.

Собранная вместе защита складывается в простую картину. Идентификатор с 256 битами энтропии не угадывается, ротация после входа не даёт закрепиться подброшенному значению, токен в заголовке ломает подделку запросов, атрибуты cookie и префиксы имён отрезают сценарии на уровне браузера. Хорошо настроенная сессия при этом остаётся незаметной: покупатель заходит, выбирает, оплачивает и не встречает ни одного лишнего экрана входа, а разработчик спокоен, потому что чужой запрос без токена не пройдёт, а перехваченный идентификатор устареет раньше, чем успеет пригодиться вору.