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
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.
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 ancorarole = admin. Disabilitato → 401, declassato → 403, subito. Test:admin.test.ts→ "admin disabilitato o declassato". - Route private (
/me*,/installer/profile):authenticate/requireRole("installer")piùrequireActiveUserin ogni handler, che rilegge l'utente (disabilitato → 401). Verificato su tutti i file diroutes/private/. - Ownership: preventivi, ticket e messaggi filtrano sempre su
user_iddell'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 daresolveCart. I documenti seguonovisibleLevels, riletti dal DB a ogni richiesta. Coperto dai test diquotes,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/*richiedonox-internal-secret(confrontotimingSafeEqualsu 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 convalidateAndTransformBody. - Nessun SQL concatenato: Drizzle parametrizza sempre. Nessun uso di
sql.raw,.unsafe()oexecutecon stringhe composte. Le interpolazioni nei templatesql\…`sono parametri (tsQuery, advisory lock). Le ricercheilikefanno 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=truee il server SSR che chiama l'API suhttp://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_PROXYun elenco di proxy fidati. In produzione valeloopback,uniquelocal, cioè si fida diX-Forwarded-Forsolo 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-Forricevuto 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).
- L'API ora accetta in
- 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 hacors: false. -
Cookie:
Cookie Attributi iti_rtHttpOnly,Secure,SameSite=Lax,Path=/authcart_idfirmato, HttpOnly,Secure,SameSite=Laxiti_sessionnon httpOnly (voluto: solo un indizio, valore 1, nessun segreto)iti_consentnon httpOnly (voluto: lo legge il banner) -
Token: access token solo in memoria in entrambe le app. In
localStorageesessionStorageci 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_DIRimpostata, 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_BYTEScon conteggio in streaming (413) e cancellazione del file parziale. - Chiavi generate dal server (
<uuid><ext>), doppio controllo anti path traversal, scrittura conwx. private/mai servita staticamente: Caddy monta solouploads/public.media.<dominio>haCSP sandboxenosniff.- 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
markede poi lo filtra consanitize-html(whitelist; nientescript,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,outerHTMLoinsertAdjacentHTML. - 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.htmlhangCspNonce="__CSP_NONCE__". La build mette quel segnaposto comenoncesugli script e sugli stili inline e lo passa a beasties (CSS critici). Durante l'SSR,CSP_NONCEdi 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-srcsull'API e Stripe,img-srcsuPUBLIC_MEDIA_URL(nuova variabile) e tile OSM,frame-ancestors 'none'. - Con
ng servela 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 suwebhook_events(id UUID v5 deterministico, lockFOR UPDATE). Coperto dawebhooks.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_KEYma mancaSTRIPE_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.examplehanno 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 emailconsolelogga solo destinatario, template e oggetto. - Corretto: in produzione
medusa-config.tsora rifiuta di partire seJWT_SECRET,COOKIE_SECRET,INTERNAL_API_SECREToWEBHOOK_SECRETmancano o hanno meno di 32 caratteri. SenzaJWT_SECRET, Medusa userebbe il defaultsupersecret. npm auditdel monorepo: 0 vulnerabilità nelle dipendenze di produzione. Ci sono 4 moderate solo di sviluppo (esbuilddentrodrizzle-kit, server di sviluppo mai avviato; il fix proposto è un downgrade).npm auditdi 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.1inmedusa/package.jsone lockfile aggiornato. Una sola copia, 4.18.1; Medusa si avvia e interroga il DB (medusa execverificato). - 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.
- lodash ≤ 4.17.23 (prototype pollution in
10. Errori e log in produzione — ok
- Mai stack trace nelle risposte. I 5xx rispondono
"Errore interno"in produzione (testapp.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.
11. Cookie banner — ok
Verificato nel prompt 28 e invariato:
- alla prima visita nessun cookie e nessuna risorsa di terzi;
iti_consentviene 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_appeiti_medusasono utenti distinti. Nessuno dei due è superuser e non hannoCREATEROLE,CREATEDBnéBYPASSRLS(verificato supg_roles).- Ciascuno è proprietario solo del proprio database;
CONNECTè revocato aPUBLIC. - 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_testin produzione, dove non serve. Dopo il primo deploy si può eliminare conDROP DATABASE iti_app_test.
Punti rimasti aperti
- Stripe end-to-end (pagamento, 3DS, carta rifiutata, webhook firmato): non verificato, mancano le chiavi di test (prompt 13 e 26).
- braces nelle dipendenze di Medusa: nessuna correzione upstream, non sfruttabile; ricontrollare agli aggiornamenti di Medusa.
- Rate limit in memoria: con più istanze dell'API, passare allo store Redis.
- HTTPS reale e HSTS: certificati Let's Encrypt e DNS da verificare sulla VPS. HSTS è senza
includeSubDomains/preload, una scelta voluta (prompt 33). - Immagini esterne negli articoli del blog: la CSP le blocca (
img-srclimitato a self, media, OSM e Stripe). Le immagini vanno caricate dall'editor. - GDPR: la cancellazione effettiva dei dati dopo la richiesta dell'utente è manuale; privacy e cookie policy sono da validare con il consulente.
iti_app_testin produzione: da eliminare dopo il primo deploy.- 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.