Naar de inhoud
CodeAuditAgent
Alle artikelen

Race conditions en TOCTOU-bugs in webapplicaties

Hoe dubbele uitgaven, kortingscodehergebruik en limietomzeiling ontstaan, en hoe je ze oplost met constraints, atomaire updates, locks en idempotentiesleutels.

· 7 min. leestijd · Lina Source LLC

De meeste webcode is geschreven alsof requests één voor één binnenkomen. Lees het saldo, controleer of het hoog genoeg is, trek af, sla op. Handmatig getest werkt het elke keer. Twintig keer parallel verstuurd, kan het hetzelfde geld twintig keer uitgeven.

Dit zijn race conditions (CWE-362), en de meest voorkomende vorm is time-of-check to time-of-use, kortweg TOCTOU (CWE-367): de applicatie controleert een voorwaarde, handelt er daarna naar, en tussendoor verandert die voorwaarde. Ze zijn makkelijk te missen in een review, omdat elke regel op zichzelf correct lijkt. De bug zit in het gat tussen twee regels.

Waarom Node.js niet immuun is

Een veelgehoorde aanname is dat singlethreaded runtimes geen race conditions kunnen hebben. JavaScript draait één callback tegelijk, maar elke await is een punt waarop een ander request kan draaien. Je database wordt gedeeld door alle instances, alle workers en alle requests. Tussen de SELECT en de UPDATE kan alles gebeuren.

// Kwetsbaar: controleren-dan-handelen over twee awaits heen
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');
  }
  // Een ander request kan dezelfde controle passeren voordat deze regel draait
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Twee requests lezen een saldo van 100, allebei passeren ze de controle voor een opname van 100, en allebei schrijven ze 0. De gebruiker haalde 200 uit een rekening met 100 erop. Omdat de tweede schrijfactie de eerste overschrijft met een waarde die uit verouderde data is berekend, valt er in de logs niets bijzonders te zien. ORM’s veranderen hier niets aan. Een record in een object laden, het object wijzigen en het opslaan is hetzelfde lees-wijzig-schrijf-patroon, met hetzelfde gat.

Waar races opduiken

  • Saldi, tegoeden en wallets: hetzelfde geld dubbel uitgeven.
  • Kortingscodes en cadeaubonnen: een code voor eenmalig gebruik meerdere keren verzilveren.
  • Abonnements- en gebruikslimieten: meer projecten, seats of API-aanroepen aanmaken dan het abonnement toestaat.
  • Registratie en uitnodigingen: twee accounts met hetzelfde e-mailadres aanmaken, of één uitnodiging tweemaal accepteren.
  • Stemmen, likes en beoordelingen: één gebruiker meer dan eens meetellen.
  • Betaal- en bestelstromen: een bestelling tweemaal afhandelen wanneer een webhook en een redirect tegelijk binnenkomen.

Misbruik is niet moeilijk. Een batch requests tegelijk versturen met Promise.all, curl of een proxytool is genoeg om vensters van enkele milliseconden te raken, en technieken als de single-packet attack laten de requests vrijwel gelijktijdig bij de server aankomen. Ga ervan uit dat als er een race bestaat, iemand hem kan winnen.

Fix 1: laat de database de regel afdwingen

De sterkste fixes verplaatsen de regel naar de database, waar gelijktijdige requests voor je worden geserialiseerd. Twee gereedschappen dekken de meeste gevallen: unieke constraints en atomaire voorwaardelijke updates.

-- Eenmalig per gebruiker: de tweede insert faalt, ongeacht de timing
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Globaal gebruiksplafond: controleren en ophogen in één statement
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Saldo: de voorwaarde wordt tegen de huidige rij geëvalueerd
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Voor de zekerheid: het saldo kan nooit negatief worden
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

De voorwaardelijke UPDATE werkt omdat de database de rij vergrendelt terwijl hij de WHERE-clausule evalueert. Racen twee requests, dan controleert de tweede de voorwaarde opnieuw tegen de rij die de eerste heeft gecommit. Komt er geen rij terug, dan is de voorwaarde niet gehaald en geef je een fout terug. Er is geen gat om te misbruiken, want de controle en de schrijfactie zijn hetzelfde statement.

Behandel de schending van een unieke constraint in applicatiecode als een normale uitkomst. In PostgreSQL komt hij als foutcode 23505 binnen; vertaal die naar een duidelijke melding zoals 'kortingscode al gebruikt' in plaats van een 500.

De meeste ORM’s kunnen de voorwaardelijke update uitdrukken. Met Prisma geeft updateMany met de saldovoorwaarde in de where-clausule een aantal terug, en een aantal van nul betekent dat de controle is mislukt. Een findUnique gevolgd door een aparte update geeft je diezelfde garantie niet, hoe zorgvuldig de controle ook is geschreven.

Fix 2: vergrendel de rij met SELECT ... FOR UPDATE

Soms vraagt de beslissing meer dan één statement: meerdere velden lezen, een prijsfunctie aanroepen, naar twee tabellen schrijven. Neem dan binnen een transactie een rijvergrendeling. SELECT ... FOR UPDATE laat elke andere transactie die dezelfde rij wil vergrendelen wachten totdat de jouwe commit of terugrolt.

Een transactie alleen is niet genoeg. Het standaard isolatieniveau van PostgreSQL is READ COMMITTED, en op dat niveau verandert het niets om de kwetsbare code van hiervoor in BEGIN en COMMIT te zetten: beide transacties lezen hetzelfde saldo, beide passeren de controle en beide schrijfacties slagen. De vergrendeling is wat de tweede transactie dwingt te wachten en daarna de gecommitte waarde te lezen.

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

Twee details doen ertoe. De vergrendeling helpt alleen als elk codepad dat het saldo wijzigt hem ook neemt; één pad dat zonder vergrendeling bijwerkt, opent de race opnieuw. En de hele reeks moet dezelfde client gebruiken: BEGIN op de ene verbinding uit de pool draaien en de SELECT op een andere levert je helemaal geen transactie op. ORM’s bieden hetzelfde patroon via interactieve transacties of ruwe queries.

Fix 3: advisory locks voor regels die over rijen heen gaan

Rijvergrendelingen helpen niet wanneer de regel gaat over rijen die nog niet bestaan, zoals 'een gebruiker op het gratis abonnement mag hoogstens drie projecten hebben'. Twee requests kunnen elk twee projecten tellen en elk een derde invoegen. Met advisory locks in PostgreSQL kun je een willekeurige sleutel vergrendelen, zoals de gebruikers-ID, voor de duur van een transactie.

Roep binnen de transactie pg_advisory_xact_lock aan met een sleutel die van de gebruiker is afgeleid, en tel en voeg daarna in. De functie neemt een 64-bits integersleutel, dus leid een stabiel geheel getal van de gebruikers-ID af, bijvoorbeeld met hashtext; een enkele botsing tussen niet-gerelateerde gebruikers kost alleen wat wachttijd, nooit correctheid. De vergrendeling wordt bij commit of rollback automatisch vrijgegeven. Een andere optie is SERIALIZABLE-isolatie, waarbij PostgreSQL de conflicterende transacties detecteert en er een afbreekt met fout 40001; dat werkt goed, mits je code afgebroken transacties opnieuw probeert.

Wat niet werkt, is een mutex in het geheugen of een JavaScript-Map met locks. Dat dekt één proces. Zodra je twee instances, twee serverless functies of een achtergrondworker draait, is de vergrendeling weg.

Fix 4: idempotentiesleutels voor retries en dubbele submits

Sommige duplicaten zijn geen aanvallen: een gebruiker dubbelklikt, een mobiele client probeert het na een time-out opnieuw, een betaalprovider levert een webhook opnieuw af. Een idempotentiesleutel maakt van 'doe dit' 'doe dit één keer'. De client genereert per logische operatie een sleutel, en de server legt die met een unieke constraint vast voordat hij het werk doet.

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

-- Claim eerst de sleutel; nul rijen terug betekent dat een ander request hem heeft
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Geeft de insert een rij terug, voer de operatie dan in dezelfde transactie uit en sla de response op. Geeft hij niets terug, zoek dan de opgeslagen response op en geef die terug, of geef 409 als het eerste request nog bezig is. Baken sleutels af op de gebruiker, zodat de ene gebruiker de sleutel van een ander niet kan afspelen of blokkeren, en laat ze na een redelijke termijn verlopen. Bij webhooks is de event-ID van de provider de natuurlijke sleutel. Overweeg een hash van de request body bij de sleutel op te slaan en hergebruik van een sleutel met een andere body te weigeren, zodat een clientbug niet stilletjes het resultaat van een andere operatie ontvangt.

Races in je code vinden

  • Zoek naar een leesactie gevolgd door een schrijfactie op dezelfde data met een await ertussen.
  • Zoek naar aantallen die vóór een insert tegen limieten worden afgezet.
  • Zoek naar 'zoek op, maak aan als het ontbreekt' zonder unieke constraint erachter.
  • Controleer of elke schrijfactie op een gevoelige waarde via hetzelfde vergrendelde of atomaire pad loopt.
  • Schrijf een test die hetzelfde request gelijktijdig afvuurt en controleert dat de invariant nog geldt.

De gelijktijdige test is het overtuigendste bewijs. Draai de operatie twintig keer met Promise.all tegen een echte database en controleer daarna het saldo, het aantal verzilveringen of het aantal rijen. Faalt hij vóór de fix en slaagt hij erna, dan is de race dicht. Draai hem vaker dan eens; een race die één op de vijf runs faalt, is nog steeds een race.

Race conditions zijn het soort bug dat een AI-reviewer kan opmerken door de hele stroom te lezen in plaats van één regel. Als CodeAuditAgent er een markeert, bevat de bevinding de CWE, de geciteerde controle en schrijfactie, een exploitscenario dat de parallelle requests beschrijft, en een patch, meestal een van de fixes hierboven. De algemene regel blijft hoe dan ook dezelfde: laat de database beslissen, want dat is het ene onderdeel dat elk request ziet.