Commits

Auditoria pròpia de la plataforma: el bloc de seguretat, tancat

Revisió completa del projecte: 40 feines al TASKS.md, el P0 de seguretat

Per on ha començat

Al matí vaig tancar una feina arrossegada: l'onboarding per a col·laboradors externs. Els specs (46 fitxers) eren la referència de fase i vivien fora del repo; ara són a specs/, amb un docs/ONBOARDING.md que va de clonar el repo a tenir l'admin a dins, i una carpeta .agents/ amb comandes i trampes per a agents d'altres ordinadors.

Després vaig fer la revisió completa que duia setmanes esperant. En va sortir un docs/TASKS.md amb 40 feines ordenades per blocs: P0 seguretat, P1 integritat de dades, P2 accessibilitat, P3 rendiment. Allò comprovat en directe contra el servidor de dev va amb ⚠; allò només sospitat, amb ?.

El forat més gros: un JWT que ningú verificava

Quatre endpoints resolien l'usuari descodificant a mà el segon segment del token, sense comprovar la firma ni l'expiració. En la pràctica, això:

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

La cookie falsa era literalment payload-token=<base64 de {"id":2}>.inventat. Ara tota la resolució d'usuari passa per payload.auth() des de src/lib/session.ts, un únic punt. De passada van caure dos forats més: qualsevol usuari es podia promoure a admin amb un PATCH a la seva pròpia fila (el guard només cobria el create), i Media era l'única col·lecció sense access d'escriptura declarat.

El simulacre que va destapar un restore trencat

El backup és íntegre; la ruta de restauració, no. Vaig restaurar el dump del dia en una base de proves i les taules coincidien amb producció, però restore.sh tenia tres problemes encadenats:

  1. Buscava el backup al lloc equivocat: llegia el sistema vell de ~/backups/, aturat des del 9 de setembre, i no el diari del contenidor.
  2. El dump el genera pg_dump 18 contra un Postgres 16.13 i porta SET transaction_timeout, que a la 16 no existeix. Amb ON_ERROR_STOP=1 avortava a la primera línia i deixava 0 taules; sense ell "funcionava" per accident.
  3. La sortida anava a /dev/null i no comptava res: un restore buit acabava dient OK.

Ara filtra els SET incompatibles, activa ON_ERROR_STOP i compta taules en acabar: menys de 10, exit 1. Verificat: amb filtre, 57 taules i agenda 1450; sense, zero.

La resta de la llista

  • El rate limit de LaIA i de subscripcions era esquivable: quatre rutes llegien el PRIMER valor de X-Forwarded-For, el que controla el visitant. Ara es pren l'últim, el que afegeix el proxy, i el limitador viu tot a src/lib/ip.ts.
  • Els beacons de publicitat anaven sense signar: demanar /ads/imp/ en bucle inflava les estadístiques i podia esgotar una campanya de pagament des de fora. El banner ara s'emet amb nonce + HMAC.
  • SendGrid s'activava per la sola presència de la clau, i en dev n'hi ha una de veritat: els correus de reset sortien de debò. Ara cal un EMAIL_TRANSPORT=sendgrid explícit.
  • findByID llança una excepció, no retorna null: cinc trucades el tractaven com a null i un id inexistent donava 500. Ara, 404.
  • Hi havia onze PGPASSWORD en clar dins scripts de test; fora del HEAD. La rotació de la contrasenya és el que queda, i és meva: la cadena viu a l'historial de GitHub.
  • Les API keys dels proveïdors d'IA es guardaven en clar; ara aes-256-gcm amb la clau derivada de l'APP_SECRET, amb migració suau per a les existents.

Com queda

El TASKS.md té 40 feines; el bloc P0, tancat. Toca el P1.