Volver al blog
Node.jsCondiciones de carreraLógica de negocioTOCTOUTransaccionesIdempotenciaPostgreSQLSeguridad de APIOWASP

Condiciones de carrera y fallos de lógica de negocio en Node.js: endpoints atómicos y defensas TOCTOU [2026]

La clase de vulnerabilidad que nunca aparece en los escaneos

Los escáneres DAST señalan XSS, las auditorías de dependencias señalan CVE y los SAST señalan patrones inseguros. Los fallos de lógica de negocio no aparecen en ninguno de ellos. Emergen más tarde, como contracargos, tickets de soporte sobre un cupón que "funcionó dos veces", inventario negativo en el sistema del almacén o un usuario cuyo saldo bajó de cero sin que nadie entendiera cómo.

El API Security Top 10 de OWASP lo ha señalado explícitamente: API6 — Acceso sin restricciones a flujos de negocio sensibles cubre el abuso de flujos como el canje de cupones, las bonificaciones por referidos y los créditos de prueba gratuita. Y la clase estrechamente relacionada — las condiciones de carrera (race conditions) (TOCTOU, time-of-check-to-time-of-use, el tiempo entre la comprobación y el uso) — es la razón por la que el código "correcto" sigue perdiendo dinero. El código es semánticamente correcto para una sola petición. Es incorrecto bajo concurrencia.

Si tienes una startup por la que cruzan dinero, créditos, inventario o tokens de un solo uso, este artículo trata sobre los bugs que de verdad te harán daño.

Que sea de un solo hilo no significa que esté libre de carreras

Node.js ejecuta JavaScript en un solo hilo, lo que confunde a muchos desarrolladores haciéndoles pensar que las condiciones de carrera son imposibles. No hay carreras de datos de memoria compartida entre dos operaciones síncronas — cierto. Pero cada await es un punto de planificación (scheduling). Mientras tu handler espera una consulta de base de datos, el event loop recoge otra petición y ejecuta su handler hasta su siguiente await. Dos peticiones se intercalan, y cada una opera sobre datos obsoletos.

Aquí está el bug canónico de comprobar-y-actuar (check-then-act), un endpoint de gasto de créditos:

js
// /api/credits/use — VULNERABLE
app.post("/api/credits/use", async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.userId } }); // await #1
  if (user.balance < 100) {
    return res.status(400).json({ error: "Insufficient credits" });
  }
  await db.user.update({
    where: { id: req.userId },
    data: { balance: user.balance - 100 }, // await #2 — writes a STALE value
  });
  res.json({ ok: true });
});

Traza dos peticiones concurrentes para un usuario con saldo 150:

  1. La petición A lee balance = 150 (await #1) y supera la comprobación.
  2. La petición B lee balance = 150 (await #1) y supera la comprobación — A todavía no ha escrito.
  3. La petición A escribe 150 - 100 = 50.
  4. La petición B escribe 150 - 100 = 50 — sobrescribiendo la escritura de A.

El usuario acaba de gastar 200 créditos teniendo 150, y el saldo final dice 50. Un atacante que pueda lanzar peticiones en paralelo (basta un solo Promise.all en un script) puede repetirlo hasta dejar la cuenta en negativo. Esto es TOCTOU: la comprobación se hace contra un valor que ya está obsoleto cuando se ejecuta la escritura.

El patrón está en todas partes: comprobaciones de stock, disponibilidad de asientos, canje de cupones, créditos por referidos, upgrades de prueba gratuita, procesamiento de webhooks. Misma forma, distinto dominio.

Las cuatro carreras clásicas

1. Doble gasto: saldos y créditos

El ejemplo anterior. La corrección consiste en convertir la comprobación y la escritura en una única sentencia atómica — sin leer-y-luego-escribir:

js
// FIXED — one conditional atomic update
const result = await db.user.updateMany({
  where: { id: req.userId, balance: { gte: 100 } }, // the check, in the WHERE
  data: { balance: { decrement: 100 } },            // the write, atomically
});
if (result.count === 0) {
  return res.status(400).json({ error: "Insufficient credits" });
}
res.json({ ok: true });

La base de datos evalúa la condición y realiza el decremento como una sola operación. Si dos peticiones compiten, una obtiene count === 1 y la otra count === 0. No es posible ningún intercalado porque no se lee nada antes de escribir — la cláusula WHERE es la comprobación, ejecutada atómicamente con la mutación.

2. Sobreventa de inventario

js
// VULNERABLE — classic stock oversell
if (product.stock > 0) {
  await db.product.update({
    where: { id: productId },
    data: { stock: product.stock - 1 },
  });
  // two concurrent buyers both see stock 1 → both buy → stock is now -1
}

Misma forma, misma corrección — la condición en el WHERE:

js
const result = await db.product.updateMany({
  where: { id: productId, stock: { gte: 1 } },
  data: { stock: { decrement: 1 } },
});
if (result.count === 0) return res.status(409).json({ error: "Out of stock" });

3. Cupones de un solo uso y códigos de un solo uso

js
// VULNERABLE — double redemption
const coupon = await db.coupon.findUnique({ where: { code } });
if (coupon.usedBy) return res.status(400).json({ error: "Already used" });
await db.coupon.update({ where: { id: coupon.id }, data: { usedBy: userId } });
// two parallel requests both see usedBy = null → both apply the discount

Dos capas de defensa:

js
// Layer 1: conditional claim — only one request can win
const claimed = await db.coupon.updateMany({
  where: { code, usedBy: null },
  data: { usedBy: userId },
});
if (claimed.count === 0) return res.status(400).json({ error: "Already used" });

// Layer 2: unique constraint — the database refuses a second claim even if
// the application logic somehow races. This is the only guarantee that
// never loses a race.
sql
ALTER TABLE coupons ADD CONSTRAINT coupons_used_by_unique
  UNIQUE NULLS NOT DISTINCT (used_by);
-- PostgreSQL 15+: UNIQUE NULLS NOT DISTINCT makes NULLs conflict too,
-- so a second "unused" claim row is impossible.

Las restricciones únicas son el último recurso de seguridad: la lógica de la aplicación puede competir, pero la base de datos no. Cuando la restricción salta, captura la violación (código de error 23505 de PostgreSQL) y devuelve un 409 limpio.

4. Webhooks e idempotencia de pagos

Stripe reintenta los webhooks que fallan. Axios reintenta las peticiones que expiran. Las apps móviles reintentan los cobros cuando se cae la conexión. Cada reintento es un duplicado — y sin idempotencia, un webhook duplicado cobra dos veces al cliente o acredita dos veces la suscripción.

El diseño estándar (el mismo que usa Stripe): el cliente envía una cabecera Idempotency-Key, y el servidor almacena la respuesta indexada por esa clave. Un reintento con la misma clave obtiene la respuesta almacenada en lugar de ejecutarse de nuevo:

sql
CREATE TABLE idempotency_keys (
  key        TEXT PRIMARY KEY,
  user_id    UUID NOT NULL,
  response   JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
js
app.post("/api/payments/charge", async (req, res) => {
  const idemKey = req.headers["idempotency-key"];
  if (!idemKey) return res.status(400).json({ error: "Missing Idempotency-Key" });

  // Claim the key INSIDE the same transaction as the charge — if the
  // transaction commits, the key is claimed; if it rolls back, the key
  // is free and the client can retry.
  try {
    const response = await db.$transaction(async (tx) => {
      const claimed = await tx.$executeRaw`
        INSERT INTO idempotency_keys (key, user_id, response)
        VALUES (${idemKey}, ${req.userId}, '{}')
        ON CONFLICT (key) DO NOTHING`;
      if (claimed === 0) {
        // Someone already processed this key — return the stored result
        const existing = await tx.idempotencyKey.findUnique({ where: { key: idemKey } });
        return existing.response;
      }
      const charge = await chargeCustomer(req.userId, req.body.amount); // external call
      await tx.idempotencyKey.update({
        where: { key: idemKey },
        data: { response: charge },
      });
      return charge;
    });
    res.json(response);
  } catch (err) {
    // If the charge fails, roll back the key claim so the client may retry.
    res.status(402).json({ error: "Charge failed" });
  }
});

El ON CONFLICT (key) DO NOTHING más la clave primaria en key es la reclamación atómica — exactamente una petición concurrente obtiene claimed === 1.

El kit de correcciones, ordenado

Cuando encuentres un patrón de comprobar-y-actuar (check-then-act), así es como elegir la defensa:

1. Actualizaciones atómicas condicionales (preferir primero)

Una sola sentencia, la condición en el WHERE, la mutación en el SET. Sin transacción necesaria, sin bloqueos retenidos, la más rápida en todos los benchmarks. Funciona siempre que el invariante sea sobre una única fila: saldos, stock, transiciones de estado, reclamaciones de un solo uso.

2. Transacciones con bloqueo de filas (invariantes de varias filas)

Cuando el invariante abarca varias filas (transferir dinero de A a B sin que la suma llegue nunca a negativo, o reservar un asiento actualizando la capacidad restante del viaje), necesitas una transacción. En PostgreSQL, SELECT ... FOR UPDATE bloquea las filas para que los escritores concurrentes esperen hasta el commit:

sql
BEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Row locked: any other writer on this row waits until COMMIT
UPDATE accounts SET balance = balance - 100 WHERE id = $1;
COMMIT;

Con Prisma, usa una transacción interactiva:

ts
await prisma.$transaction(async (tx) => {
  // Lock the row, then read the fresh value inside the lock
  const [account] = await tx.$queryRaw`
    SELECT id, balance FROM accounts WHERE id = ${accountId} FOR UPDATE`;
  if (account.balance < 100) throw new InsufficientFundsError();
  await tx.account.update({
    where: { id: accountId },
    data: { balance: { decrement: 100 } },
  });
});

Dos reglas para las transacciones: mantenlas cortas, y nunca hagas await de una llamada HTTP externa mientras retienes bloqueos — el bloqueo se mantiene durante todo el viaje de ida y vuelta, y una API de terceros lenta se convierte en un atasco de peticiones bloqueadas.

3. Restricciones únicas (el último recurso)

Todo lo que tenga semántica de "un solo uso" o "uno por usuario" recibe un índice único, y la aplicación trata la violación como un flujo de control normal. La base de datos es el único componente que nunca duerme, nunca planifica y nunca compite.

4. Bloqueo optimista (documentos de larga vida)

Para documentos editados a lo largo del tiempo (perfiles, carritos, ajustes), protégete con una columna de versión:

sql
UPDATE documents
SET body = $1, version = version + 1
WHERE id = $2 AND version = $3;
-- rowCount 0 → the document changed since you read it → return 409

El cliente envía la versión que leyó; la actualización solo se aplica si nada cambió entretanto. Barato, sin bloqueos, y el 409 le dice al cliente que vuelva a obtener los datos y reintente.

5. Claves de idempotencia (reintentos de cliente y webhooks)

Cualquier endpoint al que un cliente o proveedor pueda llamar más de una vez — pagos, consumidores de webhooks, cualquier cosa disparada por redes poco fiables — acepta una clave de idempotencia y deduplica en la capa de almacenamiento, como se mostró arriba.

6. Bloqueos distribuidos (último recurso)

SET key value NX EX de Redis (o Redlock) serializa una sección crítica entre instancias. Úsalo solo cuando la atomicidad de la base de datos genuinamente no pueda expresar el invariante (por ejemplo, un flujo de trabajo multiservicio). Los bloqueos tienen sus propios modos de fallo — un bloqueo que caduca a mitad de operación deja entrar a un segundo proceso, y un bloqueo que nunca caduca bloquea tu cola para siempre. Prefiere la base de datos.

7. Serialización dentro del proceso (instancia única)

Si ejecutas un único proceso de Node, una cola de promesas por clave serializa las operaciones sin ninguna infraestructura:

js
const queues = new Map();

function serialize(key, fn) {
  const prev = queues.get(key) ?? Promise.resolve();
  const next = prev.then(fn, fn); // run after the previous op, regardless of outcome
  queues.set(key, next.catch(() => {})); // never cache a rejected promise
  return next;
}

// Usage
app.post("/api/credits/use", (req, res) => {
  serialize(req.userId, () => spendCredits(req.userId, 100))
    .then((result) => res.json(result))
    .catch(() => res.status(400).json({ error: "Insufficient credits" }));
});

Esto no te aporta nada entre varias instancias, así que trátalo como una conveniencia de desarrollo, no como una estrategia de producción.

Fallos de lógica de negocio más allá de las carreras

Las condiciones de carrera son una variedad de fallo de lógica de negocio. Las otras variedades tienen que ver con validación que solo existe en el cliente:

1. Confiar en totales del lado del cliente

js
// VULNERABLE — price and amount come from the client
const { productId, quantity } = req.body;
const total = priceFromDb(productId) * quantity; // quantity = -1000 → negative total

Reglas que pertenecen siempre al servidor:

  • Almacena el dinero como céntimos enteros, nunca como floats — 0.1 + 0.2 !== 0.3 es un vector de explotación por redondeo a escala.
  • Calcula los totales en el servidor a partir de precios almacenados en el servidor. Nunca aceptes un importe, un descuento o una cifra de impuestos del cliente.
  • Acota toda cantidad: quantity debe ser un entero en [1, 99]. Una cantidad negativa o un importe cero es un bug de dinero gratis.
  • Devuelve 400 ante un fallo de validación, no un éxito silencioso. Una petición que "más o menos funcionó" es una pesadilla de auditoría.

2. Transiciones de estado sin protección

js
// VULNERABLE — any state can jump to any state
await db.order.update({ where: { id }, data: { status: "shipped" } });
// A concurrent refund + ship can leave an order both refunded and shipped

Modela el estado como una máquina de estados y codifica la transición en el WHERE:

js
const r = await db.order.updateMany({
  where: { id, status: "paid" }, // only paid orders may ship
  data: { status: "shipped" },
});
if (r.count === 0) return res.status(409).json({ error: "Invalid transition" });

3. Asignación masiva vía el cuerpo de la petición

js
// VULNERABLE — user can set role: "admin"
await db.user.update({ where: { id }, data: req.body });

// FIXED — explicit allowlist
const { name, email } = req.body;
await db.user.update({
  where: { id },
  data: { name, email }, // role, balance, plan: rejected by omission
});

Nunca hagas spread de req.body en un modelo. Haz allowlist de cada campo.

Cómo encontrar estos bugs en tu base de código

Las herramientas automatizadas no encuentran los fallos de lógica de negocio. Las personas, los patrones y las pruebas de carga sí:

  1. Busca patrones de comprobar-y-actuar con grep. Cada if (x.stock > 0), if (user.balance >=, if (!coupon.usedBy) seguido de una escritura es sospechoso. La señal es leer una fila y luego escribir un valor derivado de esa lectura.
  2. Traza cada await entre la lectura y la escritura. Si hay un await entre la comprobación y la mutación, tienes una ventana TOCTOU — ciérrala con una actualización condicional.
  3. Dispara peticiones concurrentes en las pruebas. Esta es la prueba de alto valor más barata que puedes escribir:
js
// 20 parallel redemptions of the same coupon — exactly 1 must succeed
const results = await Promise.all(
  Array.from({ length: 20 }, () => redeem(couponCode, userId))
);
const successes = results.filter((r) => r.ok).length;
assert.strictEqual(successes, 1); // fails on the vulnerable implementation
  1. Repite webhooks y claves duplicadas en staging. Envía el mismo evento de Stripe dos veces, la misma clave de idempotencia dos veces, y comprueba que el efecto secundario ocurre una sola vez.

Lista de verificación de despliegue

  • [ ] Las mutaciones de saldo/stock/créditos usan actualizaciones atómicas condicionales o transacciones con bloqueo de filas — sin leer-y-luego-escribir
  • [ ] Semántica de un solo uso (cupones, OTP, códigos de referido) respaldada por restricciones únicas, no por comprobaciones de la aplicación
  • [ ] Las mutaciones en conflicto devuelven 409, nunca éxito silencioso
  • [ ] Los pagos y consumidores de webhooks requieren y aplican Idempotency-Key; las respuestas almacenadas se reproducen
  • [ ] Dinero almacenado como céntimos enteros; totales calculados en el servidor; cantidades acotadas
  • [ ] Transiciones de estado protegidas con WHERE status = <anterior>
  • [ ] Sin llamadas de red externas dentro de transacciones de base de datos
  • [ ] Prueba de concurrencia en CI para cada endpoint que toca dinero (aserción de peticiones paralelas)
  • [ ] Columna de versión (bloqueo optimista) en documentos editables de larga vida
  • [ ] req.body nunca se hace spread en actualizaciones de modelos — solo allowlists explícitas

Resumen

  1. Las condiciones de carrera en Node.js son bugs de intercalado en los puntos de await, no carreras de memoria. Cada lectura → decisión de negocio → escritura a través de un await es un candidato TOCTOU.

  2. Prefiere las actualizaciones atómicas condicionales. La condición va en el WHERE, la mutación en el SET — una sentencia, sin bloqueos, sin ventana.

  3. Transacciones con SELECT ... FOR UPDATE para invariantes de varias filas — y mantenlas cortas, sin awaits externos dentro.

  4. Las restricciones únicas nunca pierden una carrera. Son la única garantía que sobrevive a los bugs de la aplicación.

  5. Claves de idempotencia para todo lo reintentable — pagos, webhooks, clientes móviles. Reclama la clave atómicamente con el trabajo.

  6. La validación de la lógica de negocio es del lado del servidor por definición: céntimos enteros, totales calculados en el servidor, cantidades acotadas, máquinas de estado protegidas, campos con allowlist.

La semana que viene: Dependency Confusion y Typosquatting — cómo los atacantes secuestran npm install con nombres de paquetes internos y grafías con errores tipográficos, y las defensas de registry, CI y lockfile que los mantienen fuera de tu build.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.