# Security review pre go-live (prompt 34) Data: 2026-10-05. Ambito: `apps/api`, `apps/public`, `apps/private`, `medusa/apps/backend`, `infra/` e `docker-compose.prod.yml`. Metodo: lettura del codice e della configurazione, `npm audit`, test automatici, verifica nel browser (build di produzione del sito, area riservata servita da Caddy con la CSP di produzione). Esiti: **ok** = conforme, nessuna modifica · **corretto** = trovato un problema e risolto in questa review · **da fare** = rimane aperto, con motivazione. ## Riepilogo | # | Voce | Esito | |---|---|---| | 1 | Autenticazione, ruoli e ownership delle route `private/` e `admin/` | corretto | | 2 | Input validati da schema, nessuna SQL concatenata | ok | | 3 | Rate limit su auth, form pubblici, configuratore, upload | corretto | | 4 | CORS, cookie, nessun token in localStorage | corretto | | 5 | Upload: whitelist, dimensione, nomi rigenerati, path traversal | ok | | 6 | HTML del blog sanitizzato, nessun `innerHTML`/`bypassSecurityTrust*` su contenuti non sanitizzati | ok | | 7 | CSP senza `'unsafe-inline'` negli script (rimandata dal prompt 33) | corretto | | 8 | Webhook Medusa e Stripe: firma e idempotenza | ok | | 9 | Segreti fuori dal repo e dai log; `npm audit` senza alte/critiche | corretto / da fare | | 10 | Errori in produzione senza stack; log senza password, token, carte | ok | | 11 | Cookie banner: nessun cookie non tecnico prima del consenso | ok | | 12 | Database: utente applicativo non superuser, utenti distinti per app e Medusa | ok | ## 1. Autenticazione, ruoli e ownership — corretto - **Area admin**: tutte le route `/admin/*` stanno dietro un unico hook (`routes/admin/index.ts`). Prima controllava solo il ruolo nel JWT, quindi un admin disabilitato o declassato manteneva l'accesso fino alla scadenza del token (≤ 15 min). **Corretto**: un secondo hook rilegge l'utente dal DB (`requireActiveUser`) e richiede ancora `role = admin`. Disabilitato → 401, declassato → 403, subito. Test: `admin.test.ts` → "admin disabilitato o declassato". - **Route private** (`/me*`, `/installer/profile`): `authenticate` / `requireRole("installer")` più `requireActiveUser` in ogni handler, che rilegge l'utente (disabilitato → 401). Verificato su tutti i file di `routes/private/`. - **Ownership**: preventivi, ticket e messaggi filtrano sempre su `user_id` dell'utente corrente (`mine()`, `loadOwn()`). Risorsa altrui, in bozza o inesistente → 404 identico. Gli ordini Medusa sono filtrati sul customer dell'utente e ricontrollati sul dettaglio (`order.customer_id`). I carrelli sono verificati da `resolveCart`. I documenti seguono `visibleLevels`, riletti dal DB a ogni richiesta. Coperto dai test di `quotes`, `tickets`, `account`, `shop`, `documents`. - **Ruolo admin non assegnabile** dalla registrazione (enum di `accountType`); JWT HS256 con algoritmo fissato in verifica. - **Anti-enumerazione alla registrazione**: con un'email già registrata la risposta era 202 come le altre, ma più veloce (nessun hash argon2), quindi i tempi rivelavano l'email. **Corretto**: anche in quel caso si calcola l'hash (`auth.ts`). Il login usava già un hash fittizio. - Medusa: le route `/internal/*` richiedono `x-internal-secret` (confronto `timingSafeEqual` su digest SHA-256); il dashboard ha whitelist IP e basic auth in Caddy. ## 2. Validazione e SQL — ok - Ogni route Fastify dichiara gli schemi zod di body, query e params (`fastify-type-provider-zod`), più lo schema di risposta. Le route interne di Medusa validano con `validateAndTransformBody`. - Nessun SQL concatenato: Drizzle parametrizza sempre. Nessun uso di `sql.raw`, `.unsafe()` o `execute` con stringhe composte. Le interpolazioni nei template `sql\`…\`` sono parametri (`tsQuery`, advisory lock). Le ricerche `ilike` fanno l'escape di `%`, `_` e `\` (`likeEscape`). Lo stesso vale in Medusa (claim con ILIKE letterale). ## 3. Rate limit — corretto | Route | Limite per IP | |---|---| | globale | 300/min | | login, reset password, verifica email, cambio password, cancellazione account | 5/min | | registrazione, password dimenticata, reinvio verifica | 3 ogni 15 min | | refresh | 30/min | | `POST /contact`, `POST /showroom/bookings`, `POST /configurator/submit` | 5 ogni 15 min | | `POST /configurator/evaluate`, `GET /forms/token` | 60/min | | ticket: nuovo / messaggio | 10/ora, 30/ora | | pagamento e completamento ordine | 20/min | **Corretto**: - **Upload admin** (`POST /admin/documents`, `/admin/uploads/image`, `/admin/quotes/:id/pdf`): avevano solo il limite globale. Ora 30/min (`UPLOAD_RATE_LIMIT`). - **`POST /shop/cart/address`**: crea o cerca un customer guest in Medusa per ogni email e registra consensi. Ora 20/min. - **IP reale dietro due proxy**: con `TRUST_PROXY=true` e il server SSR che chiama l'API su `http://api:3000`, tutte le richieste fatte durante il rendering arrivavano dall'IP del container del sito. Così condividevano un unico limite di 300/min: con un po' di traffico il sito avrebbe ricevuto 429 dall'API. - L'API ora accetta in `TRUST_PROXY` un elenco di proxy fidati. In produzione vale `loopback,uniquelocal`, cioè si fida di `X-Forwarded-For` solo dai container della rete Docker. Un client diretto non può falsificarlo, e Caddy sostituisce l'intestazione dei client. - Il server SSR inoltra l'`X-Forwarded-For` ricevuto da Caddy (`forwarded-for.interceptor.ts`), così ogni visitatore ha il suo limite. - Test: `app.test.ts` (IP da proxy fidato e da client diretto). - Nota: il contatore del rate limit è in memoria, per processo. Va bene con una sola istanza dell'API; con più istanze serve lo store Redis di `@fastify/rate-limit`. ## 4. CORS, cookie, storage — corretto - CORS: solo `CORS_ORIGINS` (sito e area riservata) con credenziali; il webhook ha `cors: false`. - Cookie: | Cookie | Attributi | |---|---| | `iti_rt` | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/auth` | | `cart_id` | firmato, `HttpOnly`, `Secure`, `SameSite=Lax` | | `iti_session` | non httpOnly (voluto: solo un indizio, valore `1`, nessun segreto) | | `iti_consent` | non httpOnly (voluto: lo legge il banner) | - Token: access token solo in memoria in entrambe le app. In `localStorage` e `sessionStorage` ci sono solo configuratore, ultimo ordine e riepilogo dell'invio, senza token (verificato con grep e nel browser). - **Corretto**: in produzione l'API ora **rifiuta di partire** con: - `AUTH_COOKIE_SECURE=false`; - origini CORS non `https://`; - `EMAIL_OUTBOX_DIR` impostata, che salverebbe su disco le email complete con i link di reset e di verifica, cioè token validi. Test in `app.test.ts`. ## 5. Upload — ok - Whitelist per estensione **e** magic bytes **e** MIME dichiarato (SVG e HTML mai ammessi), `UPLOAD_MAX_BYTES` con conteggio in streaming (413) e cancellazione del file parziale. - Chiavi generate dal server (``), doppio controllo anti path traversal, scrittura con `wx`. - `private/` mai servita staticamente: Caddy monta solo `uploads/public`. `media.` ha `CSP sandbox` e `nosniff`. - Coperto da `documents.test.ts` (14 casi) e dalla verifica del prompt 33. ## 6. HTML del blog e frontend — ok - L'API converte il markdown con `marked` e poi lo filtra con `sanitize-html` (whitelist; niente `script`, `iframe`, `style`, `on*`, `javascript:`, `data:`). - Nei frontend ci sono solo due `bypassSecurityTrustHtml`, entrambi su HTML che arriva da quel sanitizer: articolo del blog (`blog-post.ts`) e anteprima dell'editor (`blog-editor.ts`). - Nessun altro `innerHTML`, `outerHTML` o `insertAdjacentHTML`. - Verificato nel browser: markdown con `` e `