Commits

Auditoría propia de la plataforma: el bloque de seguridad, cerrado

Revisión completa del proyecto: 40 trabajos en el TASKS.md, el P0 de seguridad

Por dónde ha empezado

Por la mañana cerré una tarea arrastrada: el onboarding para colaboradores externos. Los specs (46 archivos) eran la referencia de fase y vivían fuera del repo; ahora están en specs/, con un docs/ONBOARDING.md que va de clonar el repo a tener el admin dentro, y una carpeta .agents/ con comandos y trampas para agentes de otros ordenadores.

Después hice la revisión completa que llevaba semanas esperando. De ahí salió un docs/TASKS.md con 40 trabajos ordenados por bloques: P0 seguridad, P1 integridad de datos, P2 accesibilidad, P3 rendimiento. Lo comprobado en directo contra el servidor de dev va con ⚠; lo solo sospechado, con ?.

El agujero más gordo: un JWT que nadie verificaba

Cuatro endpoints resolvían el usuario decodificando a mano el segundo segmento del token, sin comprobar la firma ni la expiración. En la práctica, esto:

GET /api/publicitat/estadistiques sin cookie   -> 401
GET /api/publicitat/estadistiques cookie falsa -> 200

La cookie falsa era literalmente payload-token=<base64 de {"id":2}>.inventado. Ahora toda la resolución de usuario pasa por payload.auth() desde src/lib/session.ts, un único punto. De paso cayeron dos agujeros más: cualquier usuario podía promocionarse a admin con un PATCH a su propia fila (el guard solo cubría el create), y Media era la única colección sin access de escritura declarado.

El simulacro que destapó un restore roto

El backup es íntegro; la ruta de restauración, no. Restauré el dump del día en una base de pruebas y las tablas coincidían con producción, pero restore.sh tenía tres fallos encadenados:

  1. Buscaba el backup en el sitio equivocado: leía el sistema viejo de ~/backups/, parado desde el 9 de septiembre, y no el diario del contenedor.
  2. El dump lo genera pg_dump 18 contra un Postgres 16.13 y lleva SET transaction_timeout, que en la 16 no existe. Con ON_ERROR_STOP=1 abortaba en la primera línea y dejaba 0 tablas; sin él "funcionaba" por accidente.
  3. La salida iba a /dev/null y no contaba nada: un restore vacío acababa diciendo OK.

Ahora filtra los SET incompatibles, activa ON_ERROR_STOP y cuenta tablas al acabar: menos de 10, exit 1. Verificado: con filtro, 57 tablas y agenda 1450; sin él, cero.

El resto de la lista

  • El rate limit de LaIA y de suscripciones era esquivable: cuatro rutas leían el PRIMER valor de X-Forwarded-For, el que controla el visitante. Ahora se toma el último, el que añade el proxy, y el limitador vive entero en src/lib/ip.ts.
  • Los beacons de publicidad iban sin firmar: pedir /ads/imp/ en bucle inflaba las estadísticas y podía agotar una campaña de pago desde fuera. El banner ahora se emite con nonce + HMAC.
  • SendGrid se activaba por la sola presencia de la clave, y en dev hay una real: los correos de reset salían de verdad. Ahora hace falta un EMAIL_TRANSPORT=sendgrid explícito.
  • findByID lanza una excepción, no devuelve null: cinco llamadas lo trataban como null y un id inexistente daba 500. Ahora, 404.
  • Había once PGPASSWORD en claro dentro de scripts de test; fuera del HEAD. La rotación de la contraseña es lo que queda, y es mía: la cadena vive en el historial de GitHub.
  • Las API keys de los proveedores de IA se guardaban en claro; ahora aes-256-gcm con la clave derivada de la APP_SECRET, con migración suave para las existentes.

Cómo queda

El TASKS.md tiene 40 trabajos; el bloque P0, cerrado. Toca el P1.