VT Security blog

El webhook de un pago llegó dos veces: cómo no acreditarlo de más

Reintentos, entregas en paralelo, replays y reembolsos que se adelantan. Cuatro controles para el receptor, probados en un laboratorio local con un proveedor ficticio.

En este artículo
  1. Cinco entregas que salen mal
  2. 1. Firma y ventana de tiempo
  3. 2. Idempotencia: anotar antes de aplicar
  4. 3. Estados que no pueden existir
  5. 4. Dead-letter, conciliación y un registro que sirva
  6. Qué revisar en el receptor que ya tenés

Tu tienda recibe un webhook: el pago de la orden 1041 está aprobado. Acreditás $ 25.000 y respondés 200. A los treinta segundos llega el mismo evento otra vez. Tu código lo procesa como nuevo y la orden queda con $ 50.000 acreditados.

Nadie atacó nada. El proveedor no vio tu 200 a tiempo y reintentó, que es lo que tiene que hacer. Stripe, por ejemplo, avisa que un endpoint puede recibir el mismo evento más de una vez y no garantiza el orden en que llegan. La pregunta de tu receptor no es si el evento es válido, sino si ya lo había anotado.

proveedorfirma y reintentaespera el 200sin respuestaentregadotu receptoracredita todoesperandoprocesando200 · aplicado200 · duplicadobase de la tiendaord_1041 acreditado$ 0ledger+ $ 25.000 · evt_0001+ $ 25.000 · evt_0001processed_eventsno la usaevt_0001ya estáel 200 llegó tardeevt_0001proveedorfirma y reintentaespera el 200sin respuestaentregadotu receptoracredita todoesperandoprocesando200 · aplicado200 · duplicadobase de la tiendaord_1041 acreditado$ 0ledger+ $ 25.000 · evt_0001+ $ 25.000 · evt_0001processed_eventsno la usaevt_0001ya estáel 200 llegó tardeevt_0001

El proveedor manda el pago aprobado de la orden 1041: $ 25.000.

Tiempo comprimido: en el laboratorio el reintento llegó a los 30 segundos.

Para mi charla en la village de CyberFinance de Ekoparty armé un laboratorio para verlo pasar: una tienda con cinco órdenes, un proveedor ficticio que firma cada webhook con HMAC y reintenta si no recibe un 2xx, y un receptor en tres versiones. Corre local, con Python y SQLite, y los montos son inventados. Las salidas de abajo son de ahí.

Cinco entregas que salen mal #

Ninguna de estas es un exploit raro. Pasan en producción un martes cualquiera:

  1. Reintento: el proveedor no vio el 200 y manda el mismo evento.
  2. Reintento concurrente: las dos entregas llegan juntas, a dos workers distintos.
  3. Replay: alguien reenvía la misma request firmada tres días después.
  4. Callback falso: un «aprobado» que el proveedor nunca mandó, firmado con otra clave.
  5. Orden invertido: el reembolso llega antes que la aprobación.

El receptor ingenuo respondió 200 a las nueve entregas. Al conciliar contra la liquidación del proveedor, las cinco órdenes quedaron mal:

text
✗ ord_1041: proveedor «approved», esperado paid $ 25.000; hay paid $ 50.000
✗ ord_1042: proveedor «approved», esperado paid $ 18.500; hay paid $ 37.000
✗ ord_1043: proveedor «approved», esperado paid $ 42.000; hay paid $ 84.000
✗ ord_1044: proveedor «sin pago», esperado created $ 0; hay paid $ 30.000
✗ ord_1045: proveedor «refunded», esperado refunded $ 0; hay paid $ 0
5 diferencias · acreditado de más: $ 115.500

Ninguna respuesta dio error. El problema estaba en la cuenta.

Conciliación contra el proveedor

entregael proveedor dicetu base
reintentoord_1041approved · $ 25.000paid · $ 50.000
reintento concurrenteord_1042approved · $ 18.500paid · $ 37.000
replay, +3 díasord_1043approved · $ 42.000paid · $ 84.000
callback falsoord_1044sin pagopaid · $ 30.000
orden invertidoord_1045refunded · $ 0paid · $ 0
diferencias5 de 5acreditado de más$ 115.500

Respondió 200 a las nueve entregas. Las cinco órdenes quedaron mal.

Montos y estados de la conciliación del laboratorio.

1. Firma y ventana de tiempo #

La firma HMAC confirma que quien envió el mensaje conoce la clave compartida; el timestamp firmado limita cuánto tiempo puede reutilizarse esa misma request. El laboratorio firma timestamp.cuerpo y acepta hasta cinco minutos de diferencia, la tolerancia que usan por defecto las librerías de Stripe. La salida del receptor endurecido, recortada:

text
11/10 11:15:30  evt_0003  misma request, +3 días  → 401  timestamp fuera de ventana: 259200 s (máx 300)
11/10 11:15:30  evt_9999  firmado con otra clave  → 401  firma inválida

Dos detalles rompen esto en la práctica. La firma se calcula sobre el cuerpo crudo: si tu framework parsea el JSON y lo vuelve a serializar antes de verificar, ya no coincide. Y la comparación tiene que ser en tiempo constante, con algo como hmac.compare_digest y no con ==.

Lo que la firma no frena es el reintento legítimo: en Stripe, cada entrega tiene su propia firma y timestamp.

2. Idempotencia: anotar antes de aplicar #

La segunda versión del receptor parece correcta. Busca el event_id; si no lo vio, acredita y recién al final lo anota. Con el reintento secuencial funciona. Con dos entregas en paralelo, no:

text
08/10 11:15:30  evt_0002  entrega A (en paralelo)  → 200  ok: payment.approved aplicado
08/10 11:15:30  evt_0002  entrega B (en paralelo)  → 200  ok: payment.approved aplicado

Las dos leyeron «no visto» antes de que ninguna escribiera. Resultado: una diferencia de $ 18.500.

las dos leen antes de que ninguna anotetiempo →worker Aworker Bevt_0002evt_0002SELECTno vistoSELECTno visto+$18.500+$18.500INSERTINSERTignorado200200ord_1042acreditado$ 0+ $ 18.500 · A+ $ 18.500 · Btiempo ↓worker Aworker Bevt_0002evt_0002SELECTno vistoSELECTno visto+$18.500+$18.500INSERTINSERTignorado200200ord_1042acreditado$ 0+ $ 18.500 · A+ $ 18.500 · B

1 de 4Las dos entregas de evt_0002 llegan juntas, una a cada worker.

Los pasos y los montos son los del laboratorio; el ancho de cada paso es ilustrativo.

La versión endurecida invierte el orden. Inserta el id del evento primero, en una tabla donde no se puede repetir, y en la misma transacción que acredita. La segunda entrega choca contra el índice, el receptor contesta 200 como duplicado y no toca nada. Simplificado:

python
con.execute("BEGIN IMMEDIATE")
try:
    marca = con.execute(
        "INSERT INTO processed_events (event_id, processed_at) VALUES (?, ?) "
        "ON CONFLICT(event_id) DO NOTHING",
        (evt["id"], now),
    )
    if marca.rowcount == 0:
        con.execute("ROLLBACK")
        return 200, "duplicado: ya procesado"
    aplicar(con, evt)
    con.execute("COMMIT")
    return 200, "aplicado"
except Exception:
    con.execute("ROLLBACK")
    raise

En Postgres o MySQL el principio es el mismo, aunque cambie la sintaxis: una restricción UNIQUE sobre el id del evento y el insert dentro de la transacción que acredita. Una caché como Redis descarta duplicados rápido, pero si la marca vive afuera de esa transacción, el hueco vuelve.

Esto cubre las reentregas del mismo evento, que es lo que prueba el laboratorio. Stripe advierte que también puede generar dos objetos Event distintos para el mismo objeto y tipo. En ese caso, los ids de evento son diferentes: la acreditación necesita además su propia clave única de negocio, por ejemplo el id del pago, para no aplicarse dos veces.

3. Estados que no pueden existir #

El receptor ingenuo deja que gane el último evento. Con el reembolso adelantado, la orden 1045 terminó «pagada» y con $ 0 acreditados, mientras el proveedor dice que la devolvió.

no está en la tablapayment.approvedpayment.refundedcreatedpaidrefundedevt_0005 primeropayment.refunded200evt_0004 +5 spayment.approvedevt_0005 +60 spayment.refundedord_1045 acreditado−$ 12.750estado refundedel proveedor dicerefunded · $ 0✓ coincide✗ no coincidedead-letterno la usaevt_0005created → payment.refundedpayment.approvedpayment.refundedcreatedpaidrefundedevt_0005 primeropayment.refunded200evt_0004 +5 spayment.approvedevt_0005 +60 spayment.refundedord_1045 acreditado−$ 12.750estado refundedel proveedor dicerefunded · $ 0✓ coincide✗ no coincidedead-letterno la usaevt_0005created → payment.refunded

1 de 3El reembolso evt_0005 llega primero. Gana el último: la orden pasa de created a refunded y descuenta $ 12.750 que nunca acreditó.

Los eventos, los códigos y los tiempos son los del laboratorio.

La versión endurecida tiene las transiciones escritas, y no hay otras:

python
TRANSITIONS = {("created", "payment.approved"): "paid",
               ("paid", "payment.refunded"): "refunded"}

created más payment.refunded no está en la tabla. El receptor contesta 409, guarda el evento en una cola de dead-letter con el motivo y no toca la orden. Como no recibió un 2xx, el proveedor reintenta más tarde, y para entonces el pago ya está aprobado:

text
11/10 11:16:30  evt_0005  llega primero            → 409  transición inválida: created → payment.refunded · a dead-letter
11/10 11:16:35  evt_0004  llega después            → 200  ok: created → paid
11/10 11:17:35  evt_0005  reintento del proveedor  → 200  ok: paid → refunded

El rollback del 409 también deshace la marca de procesado; si no, el reintento se descartaría como duplicado. Y todo esto se apoya en que el proveedor reintenta: antes de copiarlo, fijate qué hace el tuyo con un 409 y por cuánto tiempo. Stripe, por ejemplo, reintenta hasta tres días en producción.

4. Dead-letter, conciliación y un registro que sirva #

Lo que no se pudo aplicar queda en dead-letter con su motivo: no se pierde ni se fuerza. Y todos los días se concilia contra la liquidación del proveedor. Con los cuatro controles, el laboratorio termina así:

ReceptorQué haceDiferenciasAcreditado de más
IngenuoAplica todo5 de 5$ 115.500
Busca y después anotaSolo deduplica (probado con los dos reintentos)1$ 18.500
EndurecidoFirma, UNIQUE en la transacción, estados y dead-letter0$ 0

Además registra cada decisión con el id del evento, la orden y el motivo, nunca con el secreto ni la firma. Con eso, alguien puede contestar qué pasó con la orden 1045 sin adivinar.

Si tu proveedor pide devolver el 2xx antes de la lógica que tarda, como Stripe, guardá el evento crudo y aplicalo en un worker. El 2xx pasa a significar «lo tengo guardado», y el UNIQUE, los estados y la dead-letter se mudan al worker, donde se acredita.

Qué revisar en el receptor que ya tenés #

No hace falta reescribirlo. Alcanza con contestar estas seis preguntas:

  1. ¿Verificás la firma sobre el cuerpo crudo, en tiempo constante, y rechazás timestamps viejos?
  2. ¿El id del evento tiene UNIQUE en la misma transacción que el efecto? Si llegan eventos distintos para el mismo pago, ¿la acreditación también tiene una clave única?
  3. ¿Las transiciones de estado están escritas, o gana el último evento que llega?
  4. ¿Respondés 2xx solo si aplicaste el evento, ya lo tenías o lo guardaste para procesarlo?
  5. ¿Conciliás contra el proveedor todos los días?
  6. ¿Cada decisión queda registrada con el id de la orden, y ningún secreto en el log?

Si alguna respuesta es «no sé», empezá por la conciliación: es la que te dice si esto ya te pasó. La checklist de la charla, con los diagramas de estado, está junto a las slides en valentorassa.com/charlas.

Valentín Torassa. Hago videos de ciberseguridad en YouTube. Si encontraste un error, avisame en los comentarios y lo corrijo con una nota al pie.

Comentarios

Pasan por moderación antes de aparecer.

Lo próximo llega también por mail. Cada dos semanas, en Apuntes.