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

Состояния гонки и ошибки TOCTOU в веб-приложениях

Как возникают двойные списания, повторное использование купонов и обход лимитов при гонке запросов и как это чинят ограничения, атомарные update, блокировки.

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

Большая часть веб-кода пишется так, будто запросы приходят по одному. Прочитать баланс, проверить, что его хватает, вычесть, сохранить. Вручную это работает каждый раз. Отправьте двадцать таких запросов параллельно — и одни и те же деньги могут быть потрачены двадцать раз.

Это состояния гонки (CWE-362), и самая частая их форма — «время проверки против времени использования», или TOCTOU (CWE-367): приложение проверяет условие, затем действует исходя из него, а в промежутке условие меняется. На ревью их легко пропустить, потому что каждая строка по отдельности выглядит правильно. Ошибка — в зазоре между двумя строками.

Почему Node.js не защищён

Распространено убеждение, что в однопоточных средах состояний гонки быть не может. JavaScript действительно выполняет по одному колбэку за раз, но каждый await — это точка, где может выполниться другой запрос. Ваша база данных общая для всех инстансов, всех воркеров и всех запросов. Между SELECT и UPDATE может произойти что угодно.

// Уязвимо: проверка и действие разнесены двумя await
export async function withdraw(userId: string, amount: number) {
  const account = await db.account.findUnique({ where: { userId } });
  if (!account || account.balance < amount) {
    throw new Error('Insufficient funds');
  }
  // Другой запрос может пройти ту же проверку до выполнения этой строки
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Два запроса читают баланс 100, оба проходят проверку на снятие 100, и оба записывают 0. Пользователь получил 200 со счёта, где было 100. Поскольку вторая запись перезаписывает первую значением, вычисленным из устаревших данных, в логах не видно ничего необычного. ORM этого не меняют. Загрузить запись в объект, изменить объект и сохранить его — тот же шаблон «прочитать, изменить, записать» с тем же зазором.

Где возникают гонки

  • Балансы, кредиты и кошельки: двойная трата одних и тех же средств.
  • Купоны и подарочные карты: многократное использование одноразового кода.
  • Лимиты тарифа и потребления: создание большего числа проектов, мест или вызовов API, чем позволяет тариф.
  • Регистрация и приглашения: создание двух аккаунтов с одним адресом почты или двукратное принятие одного приглашения.
  • Голосование, лайки и оценки: учёт одного пользователя более одного раза.
  • Оплата и оформление заказов: двойное выполнение заказа, когда вебхук и редирект приходят одновременно.

Эксплуатировать их несложно. Достаточно отправить пачку запросов разом через Promise.all, curl или прокси-инструмент, чтобы попасть в окно в несколько миллисекунд, а приёмы вроде атаки одним пакетом заставляют запросы прийти на сервер почти одновременно. Считайте, что если гонка существует, кто-нибудь сумеет её выиграть.

Исправление 1: пусть правило обеспечивает база данных

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

-- Один раз на пользователя: вторая вставка упадёт при любом тайминге
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Общий лимит использований: проверка и инкремент одним оператором
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Баланс: условие вычисляется по текущей строке
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Подстраховка: баланс никогда не станет отрицательным
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

Условный UPDATE работает потому, что база данных блокирует строку, пока вычисляет условие WHERE. Если два запроса состязаются, второй перепроверяет условие по той строке, которую зафиксировал первый. Если ни одна строка не вернулась, условие не выполнилось и вы возвращаете ошибку. Эксплуатировать нечего: проверка и запись — это один и тот же оператор.

В коде приложения относитесь к нарушению уникальности как к обычному исходу. В PostgreSQL оно приходит с кодом ошибки 23505; преобразуйте его в понятное сообщение вроде «купон уже использован», а не в 500.

Большинство ORM умеют выражать условное обновление. В Prisma updateMany с условием на баланс в блоке where возвращает счётчик, и нулевой счётчик означает, что проверка не прошла. findUnique с последующим отдельным update такой гарантии не даёт, как бы аккуратно ни была написана проверка.

Исправление 2: блокируйте строку через SELECT ... FOR UPDATE

Иногда решению нужно больше одного оператора: прочитать несколько полей, вызвать функцию расчёта цены, записать в две таблицы. Тогда берите блокировку строки внутри транзакции. SELECT ... FOR UPDATE заставляет любую другую транзакцию, которая пытается заблокировать ту же строку, ждать, пока ваша не зафиксируется или не откатится.

Одной транзакции недостаточно. Уровень изоляции по умолчанию в PostgreSQL — READ COMMITTED, и на нём оборачивание уязвимого кода из примера выше в BEGIN и COMMIT ничего не меняет: обе транзакции читают один и тот же баланс, обе проходят проверку и обе записи успешны. Именно блокировка вынуждает вторую транзакцию подождать и затем прочитать зафиксированное значение.

import { Pool } from 'pg';

const pool = new Pool();

export async function purchase(userId: string, itemId: string) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(
      'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
      [userId],
    );
    const item = await client.query(
      'SELECT price FROM items WHERE id = $1',
      [itemId],
    );
    if (rows.length === 0 || item.rows.length === 0) {
      throw new Error('Not found');
    }

    const price = Number(item.rows[0].price);
    if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');

    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
      [price, userId],
    );
    await client.query(
      'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
      [userId, itemId, price],
    );

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Важны две детали. Блокировка помогает только если её берёт каждый путь исполнения, меняющий баланс; один путь, который обновляет его без блокировки, снова открывает гонку. И вся последовательность должна использовать один и тот же клиент: если BEGIN выполнен на одном соединении из пула, а SELECT — на другом, транзакции у вас нет вовсе. ORM предлагают тот же шаблон через интерактивные транзакции или сырые запросы.

Исправление 3: рекомендательные блокировки для правил, охватывающих несколько строк

Блокировки строк не помогают, когда правило касается строк, которых ещё нет, например «у пользователя на бесплатном тарифе может быть не больше трёх проектов». Два запроса могут каждый насчитать два проекта и каждый вставить третий. Рекомендательные блокировки PostgreSQL позволяют заблокировать произвольный ключ, например идентификатор пользователя, на время транзакции.

Внутри транзакции вызовите pg_advisory_xact_lock с ключом, производным от пользователя, затем считайте и вставляйте. Функция принимает 64-битный целочисленный ключ, поэтому получите стабильное целое из идентификатора пользователя, например через hashtext; случайная коллизия между не связанными пользователями стоит лишь небольшого ожидания, но никогда — корректности. Блокировка снимается автоматически при фиксации или откате. Другой вариант — уровень изоляции SERIALIZABLE: PostgreSQL сам обнаружит конфликтующие транзакции и прервёт одну из них с ошибкой 40001; это работает хорошо при условии, что ваш код повторяет прерванные транзакции.

Что точно не работает, так это мьютекс в памяти или JavaScript-объект Map с блокировками. Он покрывает один процесс. Как только вы запускаете два инстанса, две бессерверные функции или фоновый воркер, блокировки больше нет.

Исправление 4: ключи идемпотентности для повторов и двойных отправок

Не всякий дубликат — атака: пользователь дважды нажал кнопку, мобильный клиент повторил запрос после тайм-аута, платёжный провайдер переотправил вебхук. Ключ идемпотентности превращает «сделай это» в «сделай это один раз». Клиент генерирует ключ на каждую логическую операцию, а сервер записывает его с уникальным ограничением до начала работы.

-- Схема
CREATE TABLE idempotency_keys (
  key         text PRIMARY KEY,
  user_id     uuid NOT NULL,
  response    jsonb,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Сначала занимаем ключ; ноль строк означает, что им владеет другой запрос
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Если вставка вернула строку, выполните операцию в той же транзакции и сохраните ответ. Если не вернула ничего, найдите сохранённый ответ и верните его или верните 409, если первый запрос ещё выполняется. Ограничивайте ключи областью пользователя, чтобы один пользователь не мог воспроизвести или заблокировать ключ другого, и удаляйте их по истечении разумного срока. Для вебхуков естественный ключ — идентификатор события у провайдера. Подумайте о том, чтобы хранить рядом с ключом хеш тела запроса и отклонять повторное использование ключа с другим телом, чтобы ошибка клиента не привела к молчаливому получению результата другой операции.

Как искать гонки в своём коде

  • Ищите чтение, за которым следует запись тех же данных, с await между ними.
  • Ищите подсчёты, которые сравниваются с лимитами перед вставкой.
  • Ищите шаблон «найти, а если нет — создать» без уникального ограничения за ним.
  • Проверяйте, что каждая запись чувствительного значения идёт через один и тот же заблокированный или атомарный путь.
  • Напишите тест, который отправляет один и тот же запрос конкурентно и проверяет, что инвариант сохраняется.

Конкурентный тест — самое убедительное доказательство. Выполните операцию двадцать раз через Promise.all на настоящей базе данных, затем проверьте баланс, число использований купона или количество строк. Если тест падает до исправления и проходит после — гонка закрыта. Запустите его несколько раз: гонка, которая срабатывает в одном прогоне из пяти, всё равно остаётся гонкой.

Состояния гонки — из тех ошибок, которые ИИ-рецензент способен выявить, читая весь поток целиком, а не одну строку. Когда CodeAuditAgent помечает такую ошибку, находка содержит CWE, цитаты проверки и записи, сценарий эксплуатации с описанием параллельных запросов и патч — обычно один из перечисленных выше. Общее правило в любом случае одно: пусть решает база данных, потому что это единственный компонент, который видит все запросы.