Vai al contenuto
CodeAuditAgent
Tutti gli articoli

Race condition e bug TOCTOU nelle applicazioni web

Come nascono doppie spese, riuso dei coupon e aggiramenti dei limiti quando le richieste corrono, e come correggerli con vincoli, update atomici e lock.

· 7 min di lettura · Lina Source LLC

La maggior parte del codice web è scritta come se le richieste arrivassero una alla volta. Leggi il saldo, verifica che sia sufficiente, sottrai, salva. Provato a mano, funziona ogni volta. Inviato venti volte in parallelo, può spendere venti volte lo stesso denaro.

Questi bug sono race condition (CWE-362), e la forma più comune è quella time-of-check to time-of-use, ovvero TOCTOU (CWE-367): l'applicazione verifica una condizione, poi agisce di conseguenza, e nel frattempo la condizione cambia. Sfuggono facilmente in revisione perché ogni riga, presa da sola, sembra corretta. Il bug sta nello spazio tra due righe.

Perché Node.js non è immune

Si crede comunemente che i runtime a thread singolo non possano avere race condition. JavaScript esegue una callback alla volta, ma ogni await è un punto in cui un'altra richiesta può girare. Il tuo database è condiviso da tutte le istanze, tutti i worker e tutte le richieste. Tra la SELECT e l'UPDATE può succedere di tutto.

// Vulnerabile: verifica-poi-agisci attraverso due 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');
  }
  // Un'altra richiesta può superare lo stesso controllo prima che questa riga venga eseguita
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Due richieste leggono un saldo di 100, entrambe superano il controllo per un prelievo di 100 ed entrambe scrivono 0. L'utente ha ottenuto 200 da un conto che ne conteneva 100. Poiché la seconda scrittura sovrascrive la prima con un valore calcolato su dati ormai vecchi, i log non mostrano nulla di anomalo. Gli ORM non cambiano la sostanza. Caricare un record in un oggetto, modificare l'oggetto e salvarlo è lo stesso schema leggi-modifica-scrivi, con lo stesso spazio vuoto.

Dove compaiono le race condition

  • Saldi, crediti e portafogli: spendere due volte gli stessi fondi.
  • Coupon e gift card: riscattare più volte un codice monouso.
  • Limiti di piano e di utilizzo: creare più progetti, postazioni o chiamate API di quanto il piano consenta.
  • Registrazioni e inviti: creare due account con la stessa email, oppure accettare due volte lo stesso invito.
  • Voti, like e valutazioni: contare più di una volta lo stesso utente.
  • Flussi di pagamento e di ordine: evadere due volte un ordine quando un webhook e un redirect arrivano insieme.

Sfruttarle non è difficile. Basta inviare un lotto di richieste insieme con Promise.all, curl o uno strumento proxy per colpire finestre di pochi millisecondi, e tecniche come l'attacco a pacchetto singolo fanno arrivare le richieste al server quasi simultaneamente. Dai per scontato che, se una race condition esiste, qualcuno riuscirà a vincerla.

Correzione 1: lascia che sia il database a imporre la regola

Le correzioni più solide spostano la regola nel database, dove le richieste concorrenti vengono serializzate per te. Due strumenti coprono la maggior parte dei casi: i vincoli di unicità e gli update condizionali atomici.

-- Monouso per utente: il secondo insert fallisce, qualunque sia il tempismo
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Tetto d'uso globale: verifica e incremento in un'unica istruzione
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Saldo: la condizione è valutata sulla riga corrente
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Doppia sicurezza: il saldo non può mai diventare negativo
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

L'UPDATE condizionale funziona perché il database blocca la riga mentre valuta la clausola WHERE. Se due richieste corrono, la seconda rivaluta la condizione sulla riga che la prima ha committato. Se non torna alcuna riga, la condizione non era soddisfatta e restituisci un errore. Non c'è alcuno spazio da sfruttare, perché il controllo e la scrittura sono la stessa istruzione.

Nel codice applicativo, tratta la violazione del vincolo di unicità come un esito normale. In PostgreSQL arriva come codice di errore 23505; mappalo su un messaggio chiaro come «coupon già utilizzato» anziché su un 500.

La maggior parte degli ORM sa esprimere l'update condizionale. Con Prisma, updateMany con la condizione sul saldo nella clausola where restituisce un conteggio, e un conteggio pari a zero significa che il controllo è fallito. Una findUnique seguita da un update separato non offre la stessa garanzia, per quanta cura si metta nello scrivere il controllo.

Correzione 2: blocca la riga con SELECT ... FOR UPDATE

A volte la decisione richiede più di un'istruzione: leggere diversi campi, chiamare una funzione di prezzo, scrivere su due tabelle. In quel caso prendi un lock di riga dentro una transazione. SELECT ... FOR UPDATE costringe qualsiasi altra transazione che provi a bloccare la stessa riga ad aspettare che la tua faccia commit o rollback.

Una transazione da sola non basta. Il livello di isolamento predefinito di PostgreSQL è READ COMMITTED e, a quel livello, avvolgere in BEGIN e COMMIT il codice vulnerabile visto prima non cambia nulla: entrambe le transazioni leggono lo stesso saldo, entrambe superano il controllo ed entrambe le scritture vanno a buon fine. È il lock a costringere la seconda transazione ad aspettare e a leggere poi il valore committato.

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

Contano due dettagli. Il lock serve solo se ogni percorso di codice che modifica il saldo lo prende a sua volta; un solo percorso che aggiorna senza bloccare riapre la race condition. E l'intera sequenza deve usare lo stesso client: eseguire BEGIN su una connessione del pool e la SELECT su un'altra non dà alcuna transazione. Gli ORM offrono lo stesso schema tramite transazioni interattive o query raw.

Correzione 3: advisory lock per regole che coinvolgono più righe

I lock di riga non aiutano quando la regola riguarda righe che non esistono ancora, come «un utente del piano gratuito può avere al massimo tre progetti». Due richieste possono contare due progetti ciascuna e inserirne ciascuna un terzo. Gli advisory lock di PostgreSQL permettono di bloccare una chiave arbitraria, come l'ID utente, per la durata di una transazione.

Dentro la transazione, chiama pg_advisory_xact_lock con una chiave derivata dall'utente, poi conta e inserisci. La funzione accetta una chiave intera a 64 bit, quindi ricava un intero stabile dall'ID utente, per esempio con hashtext; una collisione occasionale tra utenti non correlati costa solo un po' di attesa, mai la correttezza. Il lock viene rilasciato automaticamente al commit o al rollback. Un'altra possibilità è l'isolamento SERIALIZABLE, che fa rilevare a PostgreSQL le transazioni in conflitto e ne interrompe una con l'errore 40001; funziona bene, a condizione che il tuo codice riprovi le transazioni interrotte.

Ciò che non funziona è un mutex in memoria o una Map JavaScript di lock. Copre un solo processo. Nel momento in cui esegui due istanze, due funzioni serverless o un worker in background, il lock non esiste più.

Correzione 4: chiavi di idempotenza per retry e doppi invii

Alcuni duplicati non sono attacchi: un utente fa doppio clic, un client mobile riprova dopo un timeout, un provider di pagamento riconsegna un webhook. Una chiave di idempotenza trasforma «fai questo» in «fai questo una volta sola». Il client genera una chiave per ogni operazione logica, e il server la registra con un vincolo di unicità prima di svolgere il lavoro.

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

-- Rivendica prima la chiave; zero righe restituite significa che è di un'altra richiesta
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Se l'insert restituisce una riga, esegui l'operazione nella stessa transazione e salva la risposta. Se non restituisce nulla, recupera la risposta salvata e restituiscila, oppure restituisci 409 se la prima richiesta è ancora in corso. Delimita le chiavi all'utente, così un utente non può riprodurre o bloccare la chiave di un altro, e falle scadere dopo un intervallo sensato. Per i webhook, l'ID evento del provider è la chiave naturale. Valuta di salvare insieme alla chiave un hash del corpo della richiesta e di rifiutare il riuso di una chiave con un corpo diverso, così un bug del client non può ricevere in silenzio il risultato di un'altra operazione.

Trovare le race condition nel tuo codice

  • Cerca una lettura seguita da una scrittura degli stessi dati con un await nel mezzo.
  • Cerca conteggi confrontati con dei limiti prima di un insert.
  • Cerca schemi del tipo «cerca, poi crea se manca» senza un vincolo di unicità alle spalle.
  • Verifica che ogni scrittura su un valore sensibile passi dallo stesso percorso bloccato o atomico.
  • Scrivi un test che invii la stessa richiesta in modo concorrente e verifichi che l'invariante regga ancora.

Il test concorrente è la prova più convincente. Esegui l'operazione venti volte con Promise.all su un database reale, poi verifica il saldo, il conteggio dei riscatti o il numero di righe. Se fallisce prima della correzione e passa dopo, la race condition è chiusa. Eseguilo più di una volta: una race condition che fallisce una esecuzione su cinque resta una race condition.

Le race condition sono il tipo di bug che un revisore IA può far emergere leggendo l'intero flusso anziché una singola riga. Quando CodeAuditAgent ne segnala una, il problema riporta il CWE, il controllo e la scrittura citati, uno scenario di exploit che descrive le richieste parallele e una patch, di solito una delle correzioni viste sopra. La regola generale non cambia: lascia decidere al database, perché è l'unico componente che vede ogni richiesta.