Plataforma centralizada y open source para recibir, normalizar, almacenar y consultar reportes de Content Security Policy (CSP) en el edge de Cloudflare.
Diseñada como un único Worker Nuxt/Nitro que actúa como colector público (un solo endpoint), procesa los reportes de forma asíncrona (Queue → R2 + D1) y expone un dashboard de administración para visualizarlos y gestionar los orígenes autorizados.
- Un único endpoint público de ingestión (
POST /api/report) compatible conreport-to,report-uriyReporting-Endpoints. - Pipeline asíncrono y resiliente: el Worker recibe, normaliza y encola; un consumer (mismo Worker) persiste en R2 (raw NDJSON) y D1 (índice consultable).
- Allowlist por aplicación (
Activa/Desactivada): solo los orígenes autorizados envían reportes; los desactivados se rechazan con403sin borrar su historial. - Modelo por aplicación (hostname + path): un mismo dominio puede tener
varios aplicativos (
app.example.com/app1,app.example.com/app2). - URL inmutable al editar: la identidad (hostname/path) se fija al crear para no re-etiquetar eventos históricos.
- Dashboard responsive (Nuxt UI v4): eventos, detalle con raw preservado, catálogo de aplicaciones y métricas de Overview.
- Idempotente: reintentos con backoff exponencial, dead-letter en R2 y
ON CONFLICT DO NOTHINGpara evitar duplicados. - Privacidad por diseño: hash de IP de origen (sin guardar IPs crudas).
Orígenes (IIS, SPA, etc.)
report-to / report-uri
│
▼
POST /api/report → validate → normalize → enqueue → 202
│
Cloudflare Queue (csp-report)
│
Consumer (hook cloudflare:queue)
│
┌───────────────────┴───────────────────┐
│ │
▼ ▼
R2 (raw NDJSON) D1 (índice)
csp-reports/… events · applications · daily_stats
Un solo Worker Nuxt/Nitro es productor y consumidor de la cola mediante el
hook nativo cloudflare:queue de Nitro. Los reintentos y dead-letters se
gestionan dentro del propio consumidor.
- El navegador/origen envía el reporte a
POST /api/report. - El endpoint valida (Content-Type, tamaño, esquema Zod) y responde
202de inmediato — nunca escribe síncronamente. - Encola el reporte normalizado en la Queue.
- El consumer recibe el batch, guarda el raw en R2 y el índice en D1 (evento + agregación diaria), re-encolando con backoff ante fallos.
| Capa | Tecnología |
|---|---|
| Framework | Nuxt 4 (Nitro) + Vue 3.5 |
| UI | Nuxt UI v4 + Tailwind CSS v4 |
| Compute | Cloudflare Workers (preset cloudflare-module) |
| Cola | Cloudflare Queues |
| Almacenamiento | Cloudflare R2 (raw) + Cloudflare D1 (índice SQLite) |
| ORM | Drizzle ORM + drizzle-kit |
| Validación | Zod |
| Estado | Pinia + VueUse |
| Tests | Vitest |
├── app/ # Frontend (Nuxt 4)
│ ├── components/ # Componentes UI y del dashboard
│ ├── layouts/ # default / dashboard / auth
│ ├── pages/ # /, /events, /events/[id], /applications
│ ├── app.config.ts # Tema Nuxt UI
│ └── app.css # Tailwind v4 + @theme
├── server/ # Backend (Nitro)
│ ├── api/ # Endpoints (/api/report, /api/events, …)
│ ├── services/ # Lógica: ingest, storage (R2/D1), analytics
│ ├── plugins/ # Hook cloudflare:queue (consumer)
│ ├── database/ # Schema Drizzle + migraciones
│ └── utils/ # db.ts, env.ts
├── shared/ # Tipos y utilidades compartidas app ↔ server
├── public/ # Assets estáticos
├── wrangler.jsonc # Config del Worker (bindings)
├── drizzle.config.ts # Config de migraciones
└── worker-configuration.d.ts # Tipos generados (no editar)
- Node.js 20+ y npm
- Cuenta de Cloudflare con Workers habilitado
- Recursos de Cloudflare creados (ver más abajo)
wranglerautenticado:npx wrangler login
wrangler d1 create csp-report # → Database ID
wrangler r2 bucket create csp-reports
wrangler queues create csp-reportCompleta los IDs resultantes en wrangler.jsonc (bindings DB, R2,
CSP_REPORT_QUEUE).
npm install
npm run cf-typegen # regenera worker-configuration.d.ts desde wrangler.jsoncCopia .env.example a .env (solo para drizzle-kit, migraciones por HTTP) y
completa:
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_DATABASE_ID=
CLOUDFLARE_D1_TOKEN= # Cloudflare Dashboard → My Profile → API TokensPara secretos del runtime usa wrangler secret put (p. ej. IP_HASH_SALT).
npm run dev # dev con nitro-cloudflare-dev (bindings remotos reales)
npm run preview # build + wrangler dev local (ejercita la cola)El config usa
"remote": trueen D1/R2: el desarrollo local opera contra los recursos remotos. Para aislarte, crea recursos de staging y ajusta los IDs.
POST https://<tu-dominio>/api/report
Content-Types soportados: application/reports+json, application/csp-report,
application/json.
| Caso | Status |
|---|---|
| Aceptado | 202 Accepted |
| Método no permitido | 405 |
| Content-Type no soportado | 415 |
| JSON inválido | 400 |
| Esquema inválido | 400 |
| Payload demasiado grande (> 64 KB) | 413 |
| Origen no autorizado (allowlist) | 403 |
| Error de encolado | 500 |
Content-Security-Policy: ... ; report-uri https://<tu-dominio>/api/reportReport-To: { "group": "csp", "max_age": 10886400, "endpoints": [ { "url": "https://<tu-dominio>/api/report" } ] }El dashboard (Nuxt UI v4) consume las APIs internas; ambas deben protegerse con Cloudflare Access (perímetro) antes de producción.
| Ruta | Descripción |
|---|---|
GET /api/events |
Paginado con filtros (hostname, directiva, disposición, URL, fechas) |
GET /api/events/:id |
Detalle + raw preservado (leído de R2) |
GET /api/applications |
Catálogo con reportes 24h y última actividad |
POST /api/applications |
Crear aplicación autorizada |
PATCH /api/applications/:id |
Actualizar metadatos (no la URL) |
DELETE /api/applications/:id |
Borrado en cascada (eventos D1 + raw R2) |
GET /api/stats/overview |
Métricas agregadas |
Cada origen registrado tiene uno de dos estados:
| Estado | Comportamiento |
|---|---|
| Activo | Autorizado: acepta reportes (202) |
| Desactivado | Suspendido: rechaza con 403 sin borrar historial |
La URL (hostname + path) es inmutable tras la creación: define la identidad que se asocia a los eventos, por lo que la edición solo permite cambiar aplicación, entorno y estado. Esto evita re-etiquetar eventos históricos.
Schema Drizzle en server/database/schema.ts (tablas applications, events,
daily_stats).
npm run db:generate # genera SQL en server/database/migrations desde el schema
npm run db:migrate # aplica migraciones a D1 (drizzle-kit, driver d1-http)
npm run db:studio # UI visual de Drizzle
npm run db:pull # introspect (revisión)Nota sobre D1 y batches: D1 limita las variables enlazadas por sentencia (~100). El consumer divide los inserts multi-fila en chunks seguros para soportar batches grandes de la cola.
npm test # unitarias + integración + seguridad (Vitest)
npm run test:watch
npx nuxi typechecknpm run build
npm run deploy # build + wrangler deployEl hook cloudflare:queue de Nitro envuelve la ejecución en waitUntil sin
propagar errores; por ello el consumidor no lanza: ante un fallo transitorio
re-encola el mensaje con backoff exponencial (hasta 5 intentos) usando
delaySeconds; agotados los intentos (o ante mensajes inválidos) escribe el
evento en R2 bajo el prefijo dead-letter/ para su revisión.
- Collector privado: solo orígenes
Activaaceptan reportes (allowlist). - Privacidad: IP de origen solo se guarda hasheada.
- Limits: tamaño máximo de payload y validación estricta de esquema.
- Dashboard protegido: diseñado para desplegarse detrás de Cloudflare Access (con Entra ID/Google como proveedor).
- Cloudflare Access + Entra ID sobre dashboard y APIs administrativas.
- WAF / rate limiting sobre el endpoint público.
- Lifecycle rules de R2 para el raw (p. ej. 90 días de retención).
- Headers de seguridad y observabilidad/alertas.