Покупатель входит в магазин раз в месяц, а его сессия живёт в браузере дольше, чем путь от главной страницы до кнопки оплаты. За этой невидимой частью интерфейса стоит контракт: сервер хранит запись о посетителе, браузер носит идентификатор в 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 складывается из трёх независимых слоёв:
- проверка заголовков Origin и Referer как быстрая первая линия отбора;
- обязательный секретный токен в скрытом поле формы или в кастомном заголовке запроса;
- атрибут 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 и префиксы имён отрезают сценарии на уровне браузера. Хорошо настроенная сессия при этом остаётся незаметной: покупатель заходит, выбирает, оплачивает и не встречает ни одного лишнего экрана входа, а разработчик спокоен, потому что чужой запрос без токена не пройдёт, а перехваченный идентификатор устареет раньше, чем успеет пригодиться вору.