Перейти к содержимому
CodeAuditAgent
Все статьи

Подводные камни JWT и сессий и как их обойти

Ошибки с JWT и сессиями, ведущие к захвату аккаунта: путаница алгоритмов, слабые секреты, непроверенные claim, хранение токенов, ротация и настоящий выход.

· Чтение: 7 мин · Lina Source LLC

JSON Web Token — разумный формат с длинным списком острых углов. Сам токен редко бывает проблемой. Ошибки живут в том, как его проверяют, где хранят, сколько он живёт и что происходит при выходе пользователя из системы. У каждой из этих ошибок один и тот же исход: у кого-то на руках токен, которого быть не должно, а ваш сервер его принимает.

Две слабости встречаются чаще всего: CWE-347, некорректная проверка криптографической подписи, и CWE-613, недостаточное истечение сессии. В примерах ниже используется jose — широко распространённая JavaScript-библиотека для JWT, работающая в Node.js, edge-средах и браузерах.

Декодировать — не значит проверить

Самая прямолинейная форма CWE-347 — чтение claim из токена без проверки его подписи. В каждой библиотеке для JWT есть функция декодирования для отладки, и в коде аутентификации она встречается чаще, чем следовало бы. Декодированный токен — это всего лишь base64, который может написать кто угодно. В JavaScript-проектах этот шаблон часто прячется в маленьком помощнике, который разбивает токен по точкам и скармливает среднюю часть в JSON.parse. Он работает во всех тестах, потому что тестовые токены валидны, и принимает любой поддельный токен в продакшене.

import { decodeJwt, jwtVerify } from 'jose';

// Уязвимо: кто угодно может выпустить токен с любым sub
const claims = decodeJwt(token);
const userId = claims.sub;

// Правильно: сначала проверяются подпись, алгоритм и claim
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;

alg none и путаница алгоритмов

Заголовок JWT сообщает, каким алгоритмом подписан токен, и этот заголовок контролирует атакующий. Из доверия к нему следуют две классические атаки. Первая — alg, установленный в none: неподписанный токен, который некоторые старые библиотеки считали валидным. Вторая — путаница алгоритмов: серверу, ожидающему RS256, но позволяющему заголовку выбрать алгоритм, можно подсунуть токен HS256, подписанный публичным ключом сервера в роли секрета HMAC. Публичный ключ публичен, поэтому атакующий может подписать что угодно.

Современные библиотеки защищают от обеих атак, и jose отвергает незащищённые токены в jwtVerify и проверяет, что тип ключа соответствует алгоритму. Не полагайтесь только на значения по умолчанию. Явно фиксируйте список алгоритмов в каждом вызове проверки, чтобы будущий рефакторинг или смена библиотеки не расширили его незаметно. Если вы принимаете несколько алгоритмов, например во время миграции ключей, перечислите их явно и используйте отдельный ключ для каждого. При ротации ключей кладите kid в заголовок и выбирайте ключ по kid из собственного списка, а не по URL, указанному в заголовке токена.

Слабые секреты подписи

Токен HS256 подписывается общим секретом. Если секрет короткий или угадываемый — «secret», название приложения или значение, скопированное из учебника, — атакующий, у которого есть хотя бы один валидный токен, переберёт его офлайн обычными инструментами и затем сможет подписывать токены для любого пользователя. На офлайн-перебор никакие ограничения частоты не действуют.

  • Используйте для HS256 не менее 32 случайных байтов, сгенерированных чем-то вроде openssl rand -base64 32.
  • Загружайте секрет из окружения или менеджера секретов и падайте при старте, если он отсутствует или слишком короткий.
  • Никогда не коммитьте его, а если он когда-либо был закоммичен — замените.
  • Если проверять токены должны несколько сервисов, а выпускать — только один, используйте асимметричный алгоритм вроде RS256 или EdDSA, чтобы у проверяющих был только публичный ключ.

Проверяйте exp, aud и iss

Валидная подпись доказывает лишь то, кто выпустил токен. Предназначен ли он вам и актуален ли он ещё, решают claim. Токен без срока действия валиден вечно. Токен, выпущенный для вашего мобильного API, не должен приниматься админским сервисом только потому, что оба доверяют одному и тому же поставщику удостоверений. Для этого и существуют aud и iss.

import { SignJWT, jwtVerify } from 'jose';

const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
  throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';

export function signAccessToken(userId: string, tokenVersion: number) {
  return new SignJWT({ tv: tokenVersion })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('10m')
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

jose проверяет exp всегда, когда он присутствует, но токен без exp иначе прошёл бы проверку. Опция requiredClaims закрывает этот пробел. Для токенов от внешнего поставщика удостоверений проверяйте их по его опубликованному набору ключей через createRemoteJWKSet и всё равно фиксируйте алгоритмы, издателя и аудиторию.

localStorage или cookie с httpOnly

Хранение токена в localStorage делает его читаемым для любого скрипта на странице. Одна XSS-уязвимость или один скомпрометированный сторонний скрипт — и токен можно отправить атакующему и использовать откуда угодно, пока он не истечёт.

Cookie с httpOnly не читается из JavaScript. XSS всё равно остаётся серьёзной проблемой, потому что внедрённый скрипт может делать запросы от имени пользователя, пока страница открыта, но украсть долгоживущие учётные данные и воспроизвести их позже он не может. Для браузерных приложений, общающихся со своим же бэкендом, cookie — лучший вариант по умолчанию. Для одностраничного приложения, обращающегося к отдельному API, разумный компромисс — держать токен доступа только в памяти, а токен обновления в cookie с httpOnly.

// Express: cookie сессии с безопасными атрибутами
res.cookie('__Host-session', accessToken, {
  httpOnly: true, // не читается из JavaScript
  secure: true, // только HTTPS; требуется префиксом __Host-
  sameSite: 'lax', // не отправляется при межсайтовых POST
  path: '/', // требуется префиксом __Host-
  maxAge: 10 * 60 * 1000, // миллисекунды, совпадает со сроком жизни токена
});

Префикс __Host- говорит браузеру отклонять cookie, если она не Secure, не имеет path, равного /, или содержит атрибут Domain; это не даёт скомпрометированному поддомену её перезаписать.

SameSite и то, что он не покрывает

SameSite=Lax блокирует cookie в межсайтовых POST-запросах и при загрузке подресурсов, что снимает большую часть классического CSRF. При этом cookie по-прежнему отправляется при переходах верхнего уровня по GET, поэтому любой GET-эндпоинт, меняющий состояние, остаётся уязвимым. Strict блокирует и их, но заодно выкидывает пользователей из системы, когда они переходят на ваш сайт по ссылке из письма или с другого сайта. None отключает защиту и требует Secure.

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

Ротация, отзыв и настоящий выход из системы

Не хранящий состояния JWT нельзя отозвать до истечения срока; это и есть плата за то, что на каждый запрос не нужно обращаться к базе данных. CWE-613 описывает, что происходит, когда об этой плате забывают: выход из системы, смена пароля и блокировка аккаунта, которые на самом деле не прекращают доступ. Удаление аккаунта, смена роли и исключение кого-то из команды — тоже события отзыва, и каждое должно вступать в силу со следующего запроса, а не с истечением токена.

  • Держите токены доступа короткоживущими — минуты, а не дни.
  • Храните токены обновления на сервере в виде хешей и ротируйте их при каждом использовании.
  • Если уже ротированный токен обновления использован повторно, считайте это кражей и отзывайте всё семейство токенов.
  • Добавьте версию токена в строку пользователя и в сам токен; увеличивайте её при выходе со всех устройств, смене пароля или блокировке.
  • Перевыпускайте идентификатор сессии при входе, чтобы предотвратить фиксацию сессии.
export async function requireUser(token: string) {
  const payload = await verifyAccessToken(token);

  const user = await db.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, tokenVersion: true, disabled: true },
  });

  // Новая версия или отключённый аккаунт немедленно прекращают доступ
  if (!user || user.disabled || user.tokenVersion !== payload.tv) {
    throw new Error('Session revoked');
  }
  return user;
}

Такая выборка возвращает одно чтение из базы данных на каждый запрос — это честная цена отзыва. Многим приложениям проще и безопаснее с непрозрачным идентификатором сессии в cookie и таблицей сессий. Выход тогда означает удаление строки. Используйте JWT там, где их свойства действительно помогают, например для короткоживущих токенов между сервисами, а не потому, что так принято в учебнике.

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

Короткий чек-лист для ревью

  • Поищите вызовы decode в путях аутентификации.
  • Убедитесь, что каждый вызов проверки фиксирует алгоритмы, издателя и аудиторию и требует exp.
  • Проверьте, как секрет подписи генерируется, загружается и валидируется при старте.
  • Найдите, где токены хранятся в браузере.
  • Проверьте, что выход, смена пароля и блокировка аккаунта завершают существующие сессии.

Эти проверки в основном сводятся к чтению путей исполнения от начала до конца — так же подходит к аудиту и CodeAuditAgent: находки приходят с цитатой строки, CWE, сценарием эксплуатации и патчем, поэтому отсутствующую проверку аудитории можно подтвердить и исправить за считаные минуты.