Files
iti-technology/SECURITY_REVIEW.md
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

15 KiB

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.

# 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 ilikefanno 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.
  • 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 (<uuid><ext>), doppio controllo anti path traversal, scrittura con wx.
  • private/ mai servita staticamente: Caddy monta solo uploads/public. media.<dominio> 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 <img onerror> e <script> nell'anteprima → tag rimossi, nessun codice eseguito.

7. Content-Security-Policy — corretto

Era il punto rimandato dal prompt 33: 'unsafe-inline' negli script di entrambe le app.

Sito (SSR) — nonce per risposta (apps/public/src/server/csp.ts):

  • src/index.html ha ngCspNonce="__CSP_NONCE__". La build mette quel segnaposto come nonce sugli script e sugli stili inline e lo passa a beasties (CSS critici). Durante l'SSR, CSP_NONCE di Angular legge lo stesso attributo per lo script di avvio dell'event replay.
  • Il server Express sostituisce il segnaposto con 128 bit casuali per ogni risposta HTML e invia l'intestazione CSP: script-src 'self' 'nonce-…' https://js.stripe.com https://*.js.stripe.com, connect-src sull'API e Stripe, img-src su PUBLIC_MEDIA_URL (nuova variabile) e tile OSM, frame-ancestors 'none'.
  • Con ng serve la CSP non viene inviata.
  • Caddy non sovrascrive più la CSP del sito. Imposta solo un default restrittivo (?Content-Security-Policy) per le risposte che non ce l'hanno: statici ed errori. Verificato con Caddy reale: la CSP con nonce passa invariata, gli statici ricevono il default.

Area riservata (statica): inlineCritical: false nella build di produzione, quindi l'index.html non contiene più script inline. CSP script-src 'self'.

Verificato nel browser:

  • sito (build di produzione + API e Medusa reali): idratazione, stylesheet attivato dallo script di beasties con il nonce, aggiunta al carrello funzionante; uno script inline iniettato viene bloccato (script-src-elem);
  • area riservata servita da Caddy con la CSP di produzione: login, dashboard admin ed editor del blog senza violazioni.

style-src 'unsafe-inline' resta: servono gli stili dei componenti Angular e gli attributi style di Leaflet. Il rischio è basso, perché gli script restano bloccati.

8. Webhook — ok

  • Medusa → Fastify: HMAC-SHA256 sul body grezzo verificato prima del parse, con timingSafeEqual. Idempotenza su webhook_events (id UUID v5 deterministico, lock FOR UPDATE). Coperto da webhooks.test.ts (13 casi).
  • Stripe → Medusa: la firma la verifica il provider ufficiale payment-stripe (webhookSecret). Caddy espone solo /hooks/payment/stripe_stripe. L'elaborazione di Medusa è idempotente sullo stato della payment session (un evento ripetuto non cattura due volte).
  • Corretto: in produzione Medusa ora avvisa all'avvio se c'è STRIPE_API_KEY ma manca STRIPE_WEBHOOK_SECRET.
  • Da fare: il flusso Stripe reale (pagamento, 3DS, webhook firmato) non è ancora verificato end-to-end. Servono le chiavi di test (vedi prompt 13 e 26).

9. Segreti e dipendenze — corretto / da fare

  • Repo: i file .env* (tranne gli .example), *.private.env.json, uploads/, backups/ e .outbox/ sono in .gitignore. Una ricerca di chiavi Stripe, Brevo, Resend e PEM tra i file che finirebbero nel repo non trova nulla; gli .example hanno solo segnaposto. Corretto: aggiunto *.stackdump (dump dei crash di Git Bash, due file non tracciati nel working tree).
  • Log: redatti authorization, cookie, set-cookie, x-internal-secret, x-signature. I body non vengono mai loggati. I token viaggiano solo nel body o nei cookie, mai negli URL dell'API. Il trasporto email console logga solo destinatario, template e oggetto.
  • Corretto: in produzione medusa-config.ts ora rifiuta di partire se JWT_SECRET, COOKIE_SECRET, INTERNAL_API_SECRET o WEBHOOK_SECRET mancano o hanno meno di 32 caratteri. Senza JWT_SECRET, Medusa userebbe il default supersecret.
  • npm audit del monorepo: 0 vulnerabilità nelle dipendenze di produzione. Ci sono 4 moderate solo di sviluppo (esbuild dentro drizzle-kit, server di sviluppo mai avviato; il fix proposto è un downgrade).
  • npm audit di Medusa: prima 110 high, tutte transitive dal pacchetto @medusajs/*, da due sole cause.
    • lodash ≤ 4.17.23 (prototype pollution in _.unset/_.omit, code injection in _.template). Corretto: overrides.lodash = ^4.18.1 in medusa/package.json e lockfile aggiornato. Una sola copia, 4.18.1; Medusa si avvia e interroga il DB (medusa exec verificato).
    • braces ≤ 3.0.3 (stack exhaustion con pattern glob annidati). Da fare: non esiste una versione corretta (3.0.3 è l'ultima). Il pacchetto arriva da fast-glob/micromatch (caricamento dei moduli all'avvio) e dal bundler del dashboard (build). Non riceve mai pattern da input esterni, quindi non è sfruttabile. Le 77 high di produzione che restano sono tutte questa catena. Da ricontrollare a ogni aggiornamento di Medusa.

10. Errori e log in produzione — ok

  • Mai stack trace nelle risposte. I 5xx rispondono "Errore interno" in produzione (test app.test.ts); gli errori di Medusa diventano 503/502 generici, con il dettaglio solo nei log.
  • Password: solo hash argon2id. Token: solo SHA-256 in DB (test auth.test.ts: nessun segreto nel dump del DB né nei log catturati).
  • Dati carta: non passano mai dai server, perché il Payment Element di Stripe li invia direttamente a Stripe.

Verificato nel prompt 28 e invariato:

  • alla prima visita nessun cookie e nessuna risorsa di terzi;
  • iti_consent viene scritto solo dopo la scelta;
  • i cookie di sessione e carrello sono tecnici, e nascono solo con login o aggiunta al carrello;
  • Stripe si carica solo al passo di pagamento (necessario al servizio richiesto);
  • le tile OSM si caricano solo dopo il clic su "Mostra mappa".

Oggi il sito non ha script di statistica o marketing; ConsentService è il punto da usare per quelli futuri.

12. Database — ok

  • iti_app e iti_medusa sono utenti distinti. Nessuno dei due è superuser e non hanno CREATEROLE, CREATEDB né BYPASSRLS (verificato su pg_roles).
  • Ciascuno è proprietario solo del proprio database; CONNECT è revocato a PUBLIC.
  • Le estensioni le crea l'init del container (superuser), non le migrazioni.
  • Postgres e Redis stanno sulla rete Docker internal, senza porte pubblicate.
  • Da fare (minore): l'init crea anche iti_app_test in produzione, dove non serve. Dopo il primo deploy si può eliminare con DROP DATABASE iti_app_test.

Punti rimasti aperti

  1. Stripe end-to-end (pagamento, 3DS, carta rifiutata, webhook firmato): non verificato, mancano le chiavi di test (prompt 13 e 26).
  2. braces nelle dipendenze di Medusa: nessuna correzione upstream, non sfruttabile; ricontrollare agli aggiornamenti di Medusa.
  3. Rate limit in memoria: con più istanze dell'API, passare allo store Redis.
  4. HTTPS reale e HSTS: certificati Let's Encrypt e DNS da verificare sulla VPS. HSTS è senza includeSubDomains/preload, una scelta voluta (prompt 33).
  5. Immagini esterne negli articoli del blog: la CSP le blocca (img-src limitato a self, media, OSM e Stripe). Le immagini vanno caricate dall'editor.
  6. GDPR: la cancellazione effettiva dei dati dopo la richiesta dell'utente è manuale; privacy e cookie policy sono da validare con il consulente.
  7. iti_app_test in produzione: da eliminare dopo il primo deploy.
  8. Utenti del dashboard Medusa: password robuste e whitelist IP (SHOP_ADMIN_ALLOWED_IPS) sull'ufficio o sulla VPN; il default ammette tutti, protetti solo dalla basic auth.