Skip to content

Repository files navigation

HookLab — Inspector de webhooks en tiempo real

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.


Qué es

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:

  1. Una URL a la que apuntar el webhook sin montar tu backend todavía.
  2. Ver el payload exacto (headers + cuerpo crudo) que llega, en tiempo real.
  3. Confirmar que la firma HMAC del proveedor es válida — y detectar reenvíos (replay) o firmas falsificadas.

Problema real

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).

Cómo funciona (arquitectura breve)

   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:

  • PubSubInMemoryPubSub (EventEmitter, dev/tests) o PostgresPubSub (Postgres LISTEN/NOTIFY, prod).
  • RepositoryInMemoryRepository (Maps) o PgRepository (tablas bins / webhook_requests).
  • La config elige la implementación por entorno: si hay DATABASE_URL usa Postgres; si no, memoria. Por eso los tests corren sin Postgres.

Seguridad HMAC

  • Stripe: header Stripe-Signature con esquema t=...,v1=.... Se firma ${timestamp}.${cuerpoCrudo} con HMAC-SHA256.
  • GitHub: header X-Hub-Signature-256 con sha256=<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.

Endurecimiento de GraphQL (AppSec)

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.

Stack

  • Backend: Node + TypeScript · Fastify 5 · Mercurius 16 (GraphQL) · subscriptions por WebSocket (protocolo graphql-transport-ws) · pg para Postgres LISTEN/NOTIFY.
  • Frontend: Next.js 15 (App Router, React 19) + cliente graphql-ws.
  • Tests: Vitest.

Cómo correr local (pasos exactos)

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:3000

Prueba 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.

Pruebas

npm test        # vitest run

43 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 /hook end-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.

Estado honesto

Hecho

  • GraphQL real: queries, mutation y subscriptions por WebSocket (verificadas end-to-end contra el servidor real).
  • Catch-all HTTP /hook/:binId que 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).
  • PgRepository y PostgresPubSub está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.
  • PostgresPubSub publica el payload serializado; NOTIFY limita 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.

Qué gap de CV cierra

  • 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).

Deploy

Las subscriptions requieren WebSocket + servidor persistente, así que no sirve un host serverless efímero para el backend. Ruta recomendada:

  1. Postgres gestionado (Neon o el Postgres de Render).
  2. Backend en Render como web service Node (hay render.yaml listo) o vía Dockerfile:
    docker build -t hooklab .
    docker run -p 4000:4000 -e DATABASE_URL=postgres://... -e NODE_ENV=production hooklab
    Variables: DATABASE_URL, NODE_ENV=production (ver .env.example). El esquema se crea solo al arrancar (PgRepository.initSchema()).
  3. Frontend Next.js en Vercel apuntando NEXT_PUBLIC_API_URL al backend.

HookLab · José Enrique De Jesús Estévez · GitHub · Portafolio

About

Inspector de webhooks en tiempo real con GraphQL subscriptions y verificacion HMAC (Stripe/GitHub) timing-safe + anti-replay. Fastify + Mercurius + graphql-ws.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages