Ir al contenido
CodeAuditAgent
Todos los artículos

Condiciones de carrera y bugs TOCTOU en aplicaciones web

Cómo ocurren los dobles gastos, la reutilización de cupones y los saltos de límites, y cómo corregirlos con restricciones, updates atómicos y bloqueos.

· 7 min de lectura · Lina Source LLC

La mayor parte del código web se escribe como si las peticiones llegaran de una en una. Lee el saldo, comprueba que es suficiente, resta, guarda. Probado a mano, funciona siempre. Enviado veinte veces en paralelo, puede gastar el mismo dinero veinte veces.

Estos bugs son condiciones de carrera (CWE-362), y la forma más habitual es la de tiempo de comprobación a tiempo de uso, o TOCTOU (CWE-367): la aplicación comprueba una condición, luego actúa sobre ella, y la condición cambia por medio. Son fáciles de pasar por alto en una revisión porque cada línea parece correcta por sí sola. El bug está en el hueco entre dos líneas.

Por qué Node.js no es inmune

Una creencia habitual es que los runtimes de un solo hilo no pueden tener condiciones de carrera. JavaScript ejecuta un callback cada vez, pero cada await es un punto en el que otra petición puede ejecutarse. Tu base de datos la comparten todas las instancias, todos los workers y todas las peticiones. Entre el SELECT y el UPDATE puede pasar cualquier cosa.

// Vulnerable: comprobar y actuar con dos await por medio
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');
  }
  // Otra petición puede pasar la misma comprobación antes de que se ejecute esta línea
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Dos peticiones leen un saldo de 100, ambas pasan la comprobación para una retirada de 100, y ambas escriben 0. El usuario sacó 200 de una cuenta con 100. Como la segunda escritura sobrescribe a la primera con un valor calculado a partir de datos obsoletos, los logs no muestran nada raro. Los ORM no cambian esto. Cargar un registro en un objeto, modificar el objeto y guardarlo es el mismo patrón de leer, modificar y escribir, con el mismo hueco.

Dónde aparecen las carreras

  • Saldos, créditos y monederos: gastar dos veces los mismos fondos.
  • Cupones y tarjetas regalo: canjear varias veces un código de un solo uso.
  • Límites de plan y de uso: crear más proyectos, asientos o llamadas a la API de los que permite el plan.
  • Registro e invitaciones: crear dos cuentas con el mismo correo, o aceptar dos veces una misma invitación.
  • Votos, likes y valoraciones: contar a un mismo usuario más de una vez.
  • Flujos de pago y de pedido: servir un pedido dos veces cuando un webhook y una redirección llegan a la vez.

Explotarlas no es difícil. Enviar un lote de peticiones a la vez con Promise.all, curl o una herramienta de proxy basta para acertar ventanas de unos pocos milisegundos, y técnicas como el ataque de un solo paquete hacen que las peticiones lleguen al servidor casi simultáneamente. Da por hecho que, si existe una carrera, alguien puede ganarla.

Corrección 1: deja que la base de datos aplique la regla

Las correcciones más fuertes llevan la regla a la base de datos, donde las peticiones concurrentes se serializan por ti. Dos herramientas cubren la mayoría de los casos: las restricciones de unicidad y los updates condicionales atómicos.

-- Un solo uso por usuario: el segundo insert falla, pase lo que pase
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Tope global de usos: comprobar e incrementar en una sola sentencia
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Saldo: la condición se evalúa contra la fila actual
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Por si acaso: el saldo nunca puede quedar en negativo
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

El UPDATE condicional funciona porque la base de datos bloquea la fila mientras evalúa la cláusula WHERE. Si dos peticiones compiten, la segunda vuelve a comprobar la condición contra la fila que dejó confirmada la primera. Si no vuelve ninguna fila, la condición falló y devuelves un error. No hay hueco que explotar porque la comprobación y la escritura son la misma sentencia.

En el código de la aplicación, trata la violación de unicidad como un resultado normal. En PostgreSQL llega como el código de error 23505; mapéalo a un mensaje claro como «cupón ya utilizado» en lugar de a un 500.

La mayoría de los ORM pueden expresar el update condicional. Con Prisma, updateMany con la condición del saldo en su cláusula where devuelve un contador, y un contador de cero significa que la comprobación falló. Un findUnique seguido de un update aparte no te da la misma garantía, por muy cuidadosamente que escribas la comprobación.

Corrección 2: bloquea la fila con SELECT ... FOR UPDATE

A veces la decisión necesita más de una sentencia: leer varios campos, llamar a una función de precios, escribir en dos tablas. Entonces toma un bloqueo de fila dentro de una transacción. SELECT ... FOR UPDATE hace que cualquier otra transacción que intente bloquear la misma fila espere hasta que la tuya haga commit o rollback.

Una transacción por sí sola no basta. El nivel de aislamiento por defecto de PostgreSQL es READ COMMITTED, y a ese nivel envolver el código vulnerable de antes en BEGIN y COMMIT no cambia nada: ambas transacciones leen el mismo saldo, ambas pasan la comprobación y ambas escrituras tienen éxito. El bloqueo es lo que obliga a la segunda transacción a esperar y después leer el valor confirmado.

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

Hay dos detalles importantes. El bloqueo solo ayuda si todas las vías de código que cambian el saldo lo toman también; una vía que actualice sin bloquear reabre la carrera. Y toda la secuencia debe usar el mismo cliente: ejecutar BEGIN en una conexión del pool y el SELECT en otra no te da transacción alguna. Los ORM ofrecen el mismo patrón mediante transacciones interactivas o consultas crudas.

Corrección 3: bloqueos consultivos para reglas que abarcan varias filas

Los bloqueos de fila no ayudan cuando la regla habla de filas que todavía no existen, como «un usuario del plan gratuito puede tener como mucho tres proyectos». Dos peticiones pueden contar dos proyectos cada una e insertar cada una el tercero. Los bloqueos consultivos de PostgreSQL te permiten bloquear una clave arbitraria, como el ID de usuario, durante toda la transacción.

Dentro de la transacción, llama a pg_advisory_xact_lock con una clave derivada del usuario, y después cuenta e inserta. La función recibe una clave entera de 64 bits, así que deriva un entero estable a partir del ID de usuario, por ejemplo con hashtext; una colisión ocasional entre usuarios no relacionados solo cuesta un poco de espera, nunca corrección. El bloqueo se libera automáticamente al hacer commit o rollback. Otra opción es el aislamiento SERIALIZABLE, que hace que PostgreSQL detecte las transacciones en conflicto y aborte una con el error 40001; funciona bien, siempre que tu código reintente las transacciones abortadas.

Lo que no funciona es un mutex en memoria ni un Map de bloqueos en JavaScript. Cubre un solo proceso. En cuanto ejecutas dos instancias, dos funciones serverless o un worker en segundo plano, el bloqueo desaparece.

Corrección 4: claves de idempotencia para reintentos y envíos duplicados

Algunos duplicados no son ataques: un usuario hace doble clic, un cliente móvil reintenta tras un timeout, un proveedor de pagos reenvía un webhook. Una clave de idempotencia convierte «haz esto» en «haz esto una sola vez». El cliente genera una clave por operación lógica, y el servidor la registra con una restricción de unicidad antes de hacer el trabajo.

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

-- Reclama la clave primero; cero filas devueltas significa que otra petición la tiene
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Si el insert devuelve una fila, ejecuta la operación en la misma transacción y guarda la respuesta. Si no devuelve nada, busca la respuesta guardada y devuélvela, o devuelve 409 si la primera petición sigue en curso. Acota las claves al usuario para que un usuario no pueda reproducir ni bloquear la clave de otro, y hazlas expirar tras una ventana razonable. Para los webhooks, el ID de evento del proveedor es la clave natural. Considera guardar junto a la clave un hash del cuerpo de la petición y rechazar la reutilización de una clave con un cuerpo distinto, para que un bug del cliente no pueda recibir en silencio el resultado de otra operación.

Cómo encontrar carreras en tu código

  • Busca una lectura seguida de una escritura del mismo dato con un await por medio.
  • Busca contadores comparados con límites antes de un insert.
  • Busca el patrón de «buscar y crear si no existe» sin una restricción de unicidad detrás.
  • Comprueba que toda escritura sobre un valor sensible pasa por la misma vía bloqueada o atómica.
  • Escribe un test que dispare la misma petición de forma concurrente y verifique que el invariante se mantiene.

El test concurrente es la evidencia más convincente. Ejecuta la operación veinte veces con Promise.all contra una base de datos real, y después verifica el saldo, el número de canjes o el número de filas. Si falla antes de la corrección y pasa después, la carrera está cerrada. Ejecútalo más de una vez; una carrera que falla una de cada cinco ejecuciones sigue siendo una carrera.

Las condiciones de carrera son el tipo de bug que un revisor de IA puede sacar a la luz leyendo el flujo completo en lugar de una sola línea. Cuando CodeAuditAgent marca una, el hallazgo lleva el CWE, la comprobación y la escritura citadas, un escenario de explotación que describe las peticiones en paralelo y un parche, normalmente una de las correcciones de arriba. La regla general es la misma en todo caso: deja que decida la base de datos, porque es el único componente que ve todas las peticiones.