Aller au contenu
CodeAuditAgent
Tous les articles

Situations de concurrence et bugs TOCTOU dans les applications web

Double dépense, coupons réutilisés, limites contournées : comment ces courses surviennent et comment les corriger par contraintes, verrous et idempotence.

· 7 min de lecture · Lina Source LLC

La plupart du code web est écrit comme si les requêtes arrivaient une par une. Lire le solde, vérifier qu’il est suffisant, soustraire, enregistrer. Testé à la main, cela fonctionne à chaque fois. Envoyé vingt fois en parallèle, cela peut dépenser vingt fois le même argent.

Ces bugs sont des situations de concurrence (CWE-362), et la forme la plus courante est le temps de vérification différent du temps d’utilisation, ou TOCTOU (CWE-367) : l’application vérifie une condition, puis agit en fonction d’elle, et la condition change entre-temps. Ils passent facilement inaperçus en revue, car chaque ligne semble correcte isolément. Le bug se trouve dans l’intervalle entre deux lignes.

Pourquoi Node.js n’est pas immunisé

Une croyance répandue veut que les runtimes monothread ne puissent pas connaître de situations de concurrence. JavaScript exécute un callback à la fois, mais chaque await est un point où une autre requête peut s’exécuter. Votre base de données est partagée par toutes les instances, tous les workers et toutes les requêtes. Entre le SELECT et l’UPDATE, tout peut arriver.

// Vulnérable : vérifier puis agir à travers deux 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');
  }
  // Une autre requête peut passer la même vérification avant cette ligne
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Deux requêtes lisent un solde de 100, toutes deux passent la vérification pour un retrait de 100, et toutes deux écrivent 0. L’utilisateur a retiré 200 d’un compte qui en contenait 100. Comme la seconde écriture écrase la première avec une valeur calculée à partir de données périmées, les logs ne montrent rien d’inhabituel. Les ORM n’y changent rien. Charger un enregistrement dans un objet, modifier l’objet et l’enregistrer, c’est le même motif lecture-modification-écriture, avec le même intervalle.

Où les situations de concurrence apparaissent

  • Soldes, crédits et portefeuilles : dépenser deux fois les mêmes fonds.
  • Coupons et cartes cadeaux : utiliser plusieurs fois un code à usage unique.
  • Limites de plan et d’usage : créer plus de projets, de sièges ou d’appels d’API que le plan ne l’autorise.
  • Inscriptions et invitations : créer deux comptes avec la même adresse e-mail, ou accepter deux fois une même invitation.
  • Votes, likes et notes : compter un utilisateur plus d’une fois.
  • Flux de paiement et de commande : traiter une commande deux fois quand un webhook et une redirection arrivent ensemble.

Les exploiter n’a rien de difficile. Envoyer un lot de requêtes d’un coup avec Promise.all, curl ou un outil proxy suffit pour atteindre des fenêtres de quelques millisecondes, et des techniques comme l’attaque en paquet unique font arriver les requêtes au serveur presque simultanément. Partez du principe que si une situation de concurrence existe, quelqu’un saura la gagner.

Correctif 1 : laissez la base de données appliquer la règle

Les correctifs les plus solides déplacent la règle dans la base de données, où les requêtes concurrentes sont sérialisées pour vous. Deux outils couvrent la plupart des cas : les contraintes d’unicité et les mises à jour conditionnelles atomiques.

-- Usage unique par utilisateur : le second insert échoue, quel que soit le timing
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Plafond d'usage global : vérifier et incrémenter en une seule instruction
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Solde : la condition est évaluée sur la ligne courante
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Ceinture et bretelles : le solde ne peut jamais devenir négatif
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

L’UPDATE conditionnel fonctionne parce que la base de données verrouille la ligne pendant qu’elle évalue la clause WHERE. Si deux requêtes se font la course, la seconde réévalue la condition sur la ligne que la première a validée. Si aucune ligne ne revient, la condition a échoué et vous renvoyez une erreur. Il n’y a aucun intervalle à exploiter, car la vérification et l’écriture sont la même instruction.

Dans le code applicatif, traitez la violation d’unicité comme un dénouement normal. Dans PostgreSQL, elle arrive sous le code d’erreur 23505 ; associez-la à un message clair tel que « coupon déjà utilisé » plutôt qu’à une erreur 500.

La plupart des ORM savent exprimer la mise à jour conditionnelle. Avec Prisma, updateMany avec la condition sur le solde dans sa clause where renvoie un compte, et un compte de zéro signifie que la vérification a échoué. Un findUnique suivi d’un update séparé ne vous offre pas la même garantie, quel que soit le soin apporté à l’écriture de la vérification.

Correctif 2 : verrouillez la ligne avec SELECT ... FOR UPDATE

Parfois, la décision demande plus d’une instruction : lire plusieurs champs, appeler une fonction de tarification, écrire dans deux tables. Prenez alors un verrou de ligne à l’intérieur d’une transaction. SELECT ... FOR UPDATE force toute autre transaction qui tente de verrouiller la même ligne à attendre que la vôtre soit validée ou annulée.

Une transaction seule ne suffit pas. Le niveau d’isolation par défaut de PostgreSQL est READ COMMITTED, et à ce niveau, envelopper le code vulnérable précédent dans BEGIN et COMMIT ne change rien : les deux transactions lisent le même solde, toutes deux passent la vérification et les deux écritures réussissent. C’est le verrou qui force la seconde transaction à attendre puis à lire la valeur validée.

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();
  }
}

Deux détails comptent. Le verrou n’aide que si tous les chemins de code qui modifient le solde le prennent aussi ; un seul chemin qui met à jour sans verrouiller rouvre la course. Et toute la séquence doit utiliser le même client : exécuter BEGIN sur une connexion du pool et le SELECT sur une autre ne vous donne aucune transaction. Les ORM offrent le même motif via des transactions interactives ou des requêtes brutes.

Correctif 3 : des verrous consultatifs pour les règles qui traversent les lignes

Les verrous de ligne n’aident pas quand la règle porte sur des lignes qui n’existent pas encore, comme « un utilisateur du plan gratuit peut avoir au plus trois projets ». Deux requêtes peuvent chacune compter deux projets et chacune en insérer un troisième. Les verrous consultatifs de PostgreSQL permettent de verrouiller une clé arbitraire, comme l’identifiant de l’utilisateur, pour la durée d’une transaction.

À l’intérieur de la transaction, appelez pg_advisory_xact_lock avec une clé dérivée de l’utilisateur, puis comptez et insérez. La fonction prend une clé entière sur 64 bits : dérivez donc un entier stable de l’identifiant de l’utilisateur, par exemple avec hashtext ; une collision occasionnelle entre utilisateurs sans rapport ne coûte qu’un peu d’attente, jamais l’exactitude. Le verrou est libéré automatiquement au commit ou au rollback. Une autre option est l’isolation SERIALIZABLE, qui amène PostgreSQL à détecter les transactions en conflit et à en annuler une avec l’erreur 40001 ; cela fonctionne bien, à condition que votre code réessaie les transactions annulées.

Ce qui ne fonctionne pas, c’est un mutex en mémoire ou une Map JavaScript de verrous. Cela ne couvre qu’un seul processus. Dès que vous exécutez deux instances, deux fonctions serverless ou un worker d’arrière-plan, le verrou disparaît.

Correctif 4 : des clés d’idempotence pour les réessais et les doubles envois

Certains doublons ne sont pas des attaques : un utilisateur double-clique, un client mobile réessaie après un timeout, un prestataire de paiement relivre un webhook. Une clé d’idempotence transforme « fais ceci » en « fais ceci une fois ». Le client génère une clé par opération logique, et le serveur l’enregistre avec une contrainte d’unicité avant d’effectuer le travail.

-- Schéma
CREATE TABLE idempotency_keys (
  key         text PRIMARY KEY,
  user_id     uuid NOT NULL,
  response    jsonb,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Réserver la clé d'abord ; zéro ligne renvoyée signifie qu'une autre requête la détient
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Si l’insert renvoie une ligne, exécutez l’opération dans la même transaction et stockez la réponse. S’il ne renvoie rien, récupérez la réponse stockée et renvoyez-la, ou renvoyez 409 si la première requête est encore en cours. Rattachez les clés à l’utilisateur pour qu’un utilisateur ne puisse ni rejouer ni bloquer la clé d’un autre, et faites-les expirer après une fenêtre raisonnable. Pour les webhooks, l’identifiant d’événement du fournisseur est la clé naturelle. Envisagez de stocker un hash du corps de la requête avec la clé et de rejeter la réutilisation d’une clé avec un corps différent, afin qu’un bug client ne puisse pas recevoir silencieusement le résultat d’une autre opération.

Trouver les situations de concurrence dans votre code

  • Cherchez une lecture suivie d’une écriture des mêmes données avec un await entre les deux.
  • Cherchez des comptages comparés à des limites avant un insert.
  • Cherchez les « chercher, puis créer si absent » sans contrainte d’unicité derrière.
  • Vérifiez que chaque écriture sur une valeur sensible passe par le même chemin verrouillé ou atomique.
  • Écrivez un test qui déclenche la même requête en parallèle et vérifie que l’invariant tient toujours.

Le test concurrent est la preuve la plus convaincante. Exécutez l’opération vingt fois avec Promise.all sur une vraie base de données, puis vérifiez le solde, le nombre d’utilisations ou le nombre de lignes. S’il échoue avant le correctif et passe après, la course est fermée. Exécutez-le plus d’une fois ; une course qui échoue une fois sur cinq reste une course.

Les situations de concurrence sont le genre de bug qu’un relecteur IA peut faire remonter en lisant le flux entier plutôt qu’une seule ligne. Lorsque CodeAuditAgent en signale une, le résultat porte la CWE, la vérification et l’écriture citées, un scénario d’exploitation décrivant les requêtes parallèles, et un correctif, généralement l’un de ceux ci-dessus. La règle générale est la même dans tous les cas : laissez la base de données décider, car c’est le seul composant qui voit toutes les requêtes.