Crea una URL temporal (bin), terceros disparan webhooks a ella y ves cada payload en vivo por GraphQL subscriptions, con verificación de firma HMAC (Stripe / GitHub) y endurecimiento de seguridad sobre GraphQL.
HookLab es un "request bin" con esteroides de AppSec. Es útil cuando integras un proveedor que envía webhooks (Stripe, GitHub, una pasarela de pago local…) y necesitas:
- Una URL a la que apuntar el webhook sin montar tu backend todavía.
- Ver el payload exacto (headers + cuerpo crudo) que llega, en tiempo real.
- Confirmar que la firma HMAC del proveedor es válida — y detectar reenvíos (replay) o firmas falsificadas.
Los webhooks fallan de formas silenciosas: el proveedor firma el cuerpo con un
secreto compartido y si tú verificas mal (comparación no timing-safe, cuerpo ya
parseado en vez del crudo, sin ventana anti-replay) aceptas eventos falsos o
rechazas los legítimos. Depurar eso "a ciegas" es lento. HookLab te deja ver
el request y el veredicto de la firma al instante, con las verificaciones
hechas correctamente (cuerpo crudo, crypto.timingSafeEqual, tolerancia de
timestamp y caché de nonces).
Proveedor (Stripe/GitHub) Panel Next.js
│ POST /hook/:binId ▲
▼ │ GraphQL subscription (WebSocket)
┌─────────────────────┐ publish(topic) ┌──────────────────────┐
│ Ingesta (Fastify) │ ─────────────────▶ │ PubSub (interface) │
│ - lee cuerpo CRUDO │ │ · InMemory (dev/test)│
│ - verifica HMAC │ │ · Postgres LISTEN/ │
│ - guarda request │ │ NOTIFY (prod) │
└─────────┬───────────┘ └──────────────────────┘
│ addRequest
▼
┌─────────────────────┐
│ Repository (interface) GraphQL (Mercurius):
│ · InMemory (dev/test) query bins/bin/requests
│ · Postgres (prod) mutation createBin
└─────────────────────┘ subscription requestReceived(binId)
Piezas clave, todas detrás de una interfaz para poder testear offline:
PubSub→InMemoryPubSub(EventEmitter, dev/tests) oPostgresPubSub(PostgresLISTEN/NOTIFY, prod).Repository→InMemoryRepository(Maps) oPgRepository(tablasbins/webhook_requests).- La config elige la implementación por entorno: si hay
DATABASE_URLusa Postgres; si no, memoria. Por eso los tests corren sin Postgres.
- Stripe: header
Stripe-Signaturecon esquemat=...,v1=.... Se firma${timestamp}.${cuerpoCrudo}con HMAC-SHA256. - GitHub: header
X-Hub-Signature-256consha256=<hex>sobre el cuerpo crudo. - Comparación en tiempo constante con
crypto.timingSafeEqual(evita fugas por timing). - Anti-replay: (1) tolerancia de timestamp de ±5 min para Stripe;
(2) caché de firmas ya vistas (
ReplayCache) para ambos. - Cada request queda con un badge:
VALID/INVALID/REPLAY/NONE.
| Control | Dónde | Qué hace |
|---|---|---|
| Límite de profundidad | depthLimitRule |
Rechaza queries con anidamiento mayor a GRAPHQL_MAX_DEPTH (default 8). |
| Límite de complejidad/costo | complexityLimitRule |
Suma el costo de campos y multiplica por argumentos de paginación (first/last/limit); rechaza si supera GRAPHQL_MAX_COMPLEXITY (default 1000). |
| Introspección off en prod | NoSchemaIntrospectionCustomRule |
Solo cuando NODE_ENV=production. |
| GraphiQL solo en dev | opción de Mercurius | El IDE web se sirve únicamente fuera de producción. |
| Rate limit | RateLimiter |
Ventana fija por IP sobre /hook/:binId. |
Límite duro de limit |
resolver requests |
Acota el argumento a 1..100 como defensa en profundidad. |
- Backend: Node + TypeScript · Fastify 5 · Mercurius 16 (GraphQL) ·
subscriptions por WebSocket (protocolo
graphql-transport-ws) ·pgpara PostgresLISTEN/NOTIFY. - Frontend: Next.js 15 (App Router, React 19) + cliente
graphql-ws. - Tests: Vitest.
Backend:
# en la raíz del proyecto
npm install --no-audit --no-fund
cp .env.example .env # opcional; sin DATABASE_URL corre en memoria
npm run build
npm start # http://localhost:4000 (GraphiQL en /graphiql)Frontend (en otra terminal):
cd web
npm install --no-audit --no-fund
cp .env.example .env.local # NEXT_PUBLIC_API_URL=http://localhost:4000
npm run dev # http://localhost:3000Prueba manual rápida (sin frontend): crea un bin por GraphiQL o curl, y
dispara un webhook:
# 1) crear un bin
curl -s localhost:4000/graphql -H 'content-type: application/json' \
-d '{"query":"mutation{createBin{id}}"}'
# 2) disparar un webhook al bin (usa el id devuelto)
curl -s -XPOST localhost:4000/hook/EL_ID -H 'content-type: application/json' \
-d '{"hola":"mundo"}'En el panel (o suscribiéndote a requestReceived) verás el request aparecer en
vivo.
npm test # vitest run43 pruebas, todas verdes, 100% offline (sin Postgres ni red). Cubren:
- HMAC Stripe/GitHub (
src/security/hmac.test.ts, 16): firma válida pasa; inválida falla; cuerpo alterado falla; timestamp viejo ⇒REPLAY; firma repetida ⇒REPLAY; comparación timing-safe; header ausente/mal formado. - Endurecimiento GraphQL (
src/security/graphqlHardening.test.ts, 6): query profunda/costosa se rechaza; query normal pasa; profundidad a través de fragments; multiplicador de paginación en el costo. - PubSub en memoria (
src/pubsub/InMemoryPubSub.test.ts, 5):publish → receive, encolado, aislamiento por topic, cierre, fan-out. - Ingesta + store roundtrip (
src/service/ingest.test.ts, 7):createBin → ingest → listRequests; la subscription recibe el webhook; firma válida/ inválida/replay reflejada en el request. - Rate limiter (
src/security/rateLimiter.test.ts, 3). - Servidor de integración (
src/server.test.ts, 6): mutation/query roundtrip,POST /hookend-to-end, firma válida vía HTTP, 404 de bin inexistente, rechazo por depth limit,/health.
Además, el flujo real de subscription por WebSocket (crear bin → suscribirse
con graphql-ws → disparar webhook → recibirlo en vivo) fue verificado
manualmente contra el servidor arrancado.
Hecho
- GraphQL real: queries, mutation y subscriptions por WebSocket (verificadas end-to-end contra el servidor real).
- Catch-all HTTP
/hook/:binIdque guarda y publica el request. - Verificación HMAC Stripe y GitHub con timing-safe + anti-replay.
- Endurecimiento GraphQL (depth, complejidad, introspección off en prod, rate limit).
- Doble implementación (memoria / Postgres) detrás de interfaces; 43 tests verdes offline.
- Panel Next.js que crea bins, muestra la URL y lista los webhooks en vivo con
badge de firma. Compila (
next build) sin errores.
Pendiente / limitaciones
- No hay despliegue en vivo todavía: las subscriptions necesitan un host persistente con WebSocket + Postgres (ver más abajo). (demo en vivo pendiente).
PgRepositoryyPostgresPubSubestán implementados y tipados, pero sus tests corren sobre las implementaciones en memoria; no se ejecutó una suite de integración contra un Postgres real en este entorno.PostgresPubSubpublica el payload serializado;NOTIFYlimita el payload a ~8000 bytes. Para webhooks grandes en producción convendría publicar solo el id y leer el cuerpo del repositorio (documentado en el código).- El rate limit es en memoria (un solo proceso); multi-instancia requeriría Redis.
- Persistencia de bins es indefinida; falta expiración/TTL de bins y requests.
- GraphQL real (queries + mutations + subscriptions), que era un gap declarado, montado sobre lo que ya domino (Fastify, HMAC, arquitectura por capas).
- Webhooks entrantes + verificación de firma (otro gap): recepción, cuerpo crudo y HMAC hechos correctamente.
- Tiempo real por WebSocket.
- AppSec sobre GraphQL (depth/complexity limiting, introspección off, rate limiting).
Las subscriptions requieren WebSocket + servidor persistente, así que no sirve un host serverless efímero para el backend. Ruta recomendada:
- Postgres gestionado (Neon o el Postgres de Render).
- Backend en Render como web service Node (hay
render.yamllisto) o víaDockerfile:Variables:docker build -t hooklab . docker run -p 4000:4000 -e DATABASE_URL=postgres://... -e NODE_ENV=production hooklabDATABASE_URL,NODE_ENV=production(ver.env.example). El esquema se crea solo al arrancar (PgRepository.initSchema()). - Frontend Next.js en Vercel apuntando
NEXT_PUBLIC_API_URLal backend.
HookLab · José Enrique De Jesús Estévez · GitHub · Portafolio