fix(security): audit completo — secondo gate admin, hardening slug, XSS, CSP/HSTS, update CVE
Audit di sicurezza su tutta l'app. Report in .planning/SECURITY-SCAN.md (codice), .planning/SECURITY-AUDIT-INFRA.md (dipendenze/segreti/deploy) e piano in .planning/SECURITY-REMEDIATION-PLAN.md. CRITICO — l'autorizzazione admin era un unico punto di rottura: nessuna delle 21 pagine /admin controllava la sessione e admin/layout.tsx renderizzava comunque i figli quando mancava. L'unico guard era proxy.ts, su un Next.js affetto da GHSA-6gpp-xcg3-4w24 (proxy bypass). Ora il layout è un secondo gate indipendente; proxy.ts marca il path con un token derivato da NEXTAUTH_SECRET, così il gate non è aggirabile forgiando header e fallisce chiuso se il proxy non gira. ALTO — gli slug cliente avevano 4 caratteri casuali da Math.random() (~20 bit, 1.7M tentativi) e risolvono prima del token: ora 12 caratteri via nanoid (CSPRNG, ~62 bit). Aggiunto rate limit al ramo /client/, che ne era privo. ALTO — src/lib/quote-actions.ts esponeva due server action pubbliche senza autenticazione, una delle quali scriveva su DB. Codice morto, zero chiamanti: rimosso. MEDIO — i quattro dangerouslySetInnerHTML nelle sezioni proposta rendevano output AI come HTML grezzo su pagina pubblica, alimentato da transcript di terzi. Sostituiti con RichText (whitelist di emphasis, nessun HTML al DOM). I transcript ora sono recintati in tag che il system prompt dichiara essere dati, non istruzioni. Inoltre: next 16.2.6 -> 16.2.12 e next-auth 4.24.14 -> 4.24.15 (chiude 9 CVE Next piu GHSA-xmf8-cvqr-rfgj su getToken, raggiungibile dal proxy); HSTS e CSP; potatura della Map di rate-limit.ts, che cresceva senza limite; espunta la password Postgres di produzione dai due 07-01-SUMMARY.md. Verificato: tsc pulito, build OK, smoke test su login/redirect/header forgiati. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# Security Audit — Infra / Dependencies / Secrets lane
|
||||
|
||||
Data: 2026-07-27 · Branch: `main` · Commit: `dd2d148`
|
||||
Autore: audit manuale (lane complementare allo scan multi-agente del plugin `claude-security`,
|
||||
il cui report vive in `.planning/SECURITY-SCAN.md`).
|
||||
|
||||
Questo file copre ciò che lo scan del codice **non** guarda: dipendenze, segreti in git,
|
||||
configurazione di deploy e superficie di rete in produzione.
|
||||
|
||||
---
|
||||
|
||||
## 1. CRITICO — Password Postgres di produzione committata in chiaro
|
||||
|
||||
**Dove:**
|
||||
- `.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md:188,195,202`
|
||||
- `.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md:188,195,202`
|
||||
|
||||
**Cosa:** stringa completa in plaintext, dentro git, con credenziale reale:
|
||||
|
||||
```
|
||||
postgresql://clienthub:<PASSWORD-IN-CHIARO>@178.104.27.55:5432/clienthub?sslmode=disable
|
||||
```
|
||||
|
||||
**Verificato:** la password presente in quei file è **ancora attiva** — coincide con la voce
|
||||
`DATABASE_URL` (porta 5432) di `.env.local`. Non è stata ruotata.
|
||||
|
||||
**Aggravanti:**
|
||||
- `sslmode=disable` → traffico Postgres in chiaro sulla rete.
|
||||
- Il commit è nella storia di git: cancellare il file **non basta**, la credenziale resta
|
||||
recuperabile da qualunque clone o dal remote Gitea.
|
||||
|
||||
**Impatto:** chiunque abbia (o abbia avuto) accesso in lettura al repo Gitea, o a un clone locale,
|
||||
possiede la credenziale del DB di produzione — che contiene `clients`, `payments`, `projects`.
|
||||
|
||||
**Nota:** la seconda `DATABASE_URL` di `.env.local` (porta 54321, quella effettivamente usata in
|
||||
runtime secondo `project_phase11_pending_migration`) ha una password diversa e **non** risulta
|
||||
leakata. Il leak riguarda la 5432.
|
||||
|
||||
---
|
||||
|
||||
## 2. ALTO — `INTERNAL_SECRET` non configurato in produzione → oracolo di enumerazione token
|
||||
|
||||
**Dove:** `src/app/api/internal/validate-token/route.ts`, `src/app/api/internal/validate-slug/route.ts`
|
||||
|
||||
Le due route applicano il segreto **solo se la env var è presente**:
|
||||
|
||||
```ts
|
||||
const secret = process.env.INTERNAL_SECRET;
|
||||
if (secret && request.headers.get("x-internal-secret") !== secret) { ...403 }
|
||||
```
|
||||
|
||||
**Verificato in produzione** (`https://hub.iamcavalli.net`), 27/07/2026:
|
||||
|
||||
| Richiesta | Atteso se il segreto fosse attivo | Osservato |
|
||||
|---|---|---|
|
||||
| `GET /api/internal/validate-slug?slug=<inesistente>` senza header | `403` | **`404`** |
|
||||
| stessa richiesta con `x-internal-secret: wrong` | `403` | **`404`** |
|
||||
| `GET /api/internal/validate-slug` senza parametro | — | `400 {"valid":false}` (route viva) |
|
||||
|
||||
Il fallback `?? ""` in `src/proxy.ts` maschera il problema: il proxy funziona lo stesso, quindi
|
||||
il difetto è invisibile in esercizio.
|
||||
|
||||
**Impatto:** le route sono **pubblicamente raggiungibili da Internet** (il `matcher` del proxy copre
|
||||
solo `/admin`, `/client`, `/quote` — **non** `/api/internal`), senza autenticazione e **senza rate
|
||||
limit**. Sono un oracolo binario valido/non-valido per token e slug dei clienti: distinguono `404`
|
||||
(non esiste) da `200` (esiste). Un attaccante può forzare slug brevi e prevedibili e ottenere
|
||||
l'accesso completo alla dashboard di un cliente, che è l'unico controllo d'accesso del portale.
|
||||
|
||||
`.env.local` ha `INTERNAL_SECRET` valorizzato (44 char) → è una lacuna della config Coolify, non del codice.
|
||||
|
||||
---
|
||||
|
||||
## 3. ALTO — Next.js 16.2.6 vulnerabile a bypass del Proxy/Middleware
|
||||
|
||||
`next@16.2.6` installato. Advisory rilevanti (tutte fixate in **16.2.11**):
|
||||
|
||||
| Advisory | Titolo |
|
||||
|---|---|
|
||||
| GHSA-6gpp-xcg3-4w24 | **Middleware / Proxy bypass in App Router** |
|
||||
| GHSA-955p-x3mx-jcvp | Unauthenticated disclosure of internal Server Function endpoints |
|
||||
| GHSA-89xv-2m56-2m9x | SSRF in Server Actions on custom servers |
|
||||
| GHSA-p9j2-gv94-2wf4 | SSRF in rewrites via attacker-controlled destination hostname |
|
||||
| GHSA-68g3-v927-f742 / GHSA-4633-3j49-mh5q | Cache confusion of response bodies |
|
||||
| GHSA-m99w-x7hq-7vfj / GHSA-4c39-4ccg-62r3 | DoS via Server Actions |
|
||||
| GHSA-q8wf-6r8g-63ch | DoS in Image Optimization API (SVG) |
|
||||
|
||||
**Perché è grave qui in particolare:** l'intera autorizzazione admin di questa app poggia su
|
||||
`src/proxy.ts`. Un bypass del proxy = accesso non autenticato a `/admin/*`. Non c'è un secondo
|
||||
livello di difesa a livello di pagina.
|
||||
|
||||
---
|
||||
|
||||
## 4. CRITICO (npm) — `next-auth` 4.24.14
|
||||
|
||||
| Advisory | CVSS | Titolo |
|
||||
|---|---|---|
|
||||
| GHSA-xmf8-cvqr-rfgj | 7.5 | `getToken()` solleva un'eccezione non gestita su header `Authorization: Bearer` malformato |
|
||||
| GHSA-x445-f3h2-j279 | 6.8 | cookie di state/nonce/PKCE non legati al provider |
|
||||
| GHSA-7rqj-j65f-68wh | — | bypass omoglifo `@` nel normalizzatore email |
|
||||
|
||||
`src/proxy.ts` chiama `getToken()` su **ogni** richiesta `/admin/*`. GHSA-xmf8-cvqr-rfgj è quindi
|
||||
direttamente raggiungibile: un header `Authorization` malformato fa esplodere il guard.
|
||||
Fix: `next-auth` ≥ 4.24.15. Gli altri due non si applicano (nessun provider OAuth, nessun login via email).
|
||||
|
||||
---
|
||||
|
||||
## 5. MEDIO — Altre dipendenze
|
||||
|
||||
| Pacchetto | Sev | Note |
|
||||
|---|---|---|
|
||||
| `postcss` ≤8.5.17 | high | transitiva via `next`; si risolve aggiornando next |
|
||||
| `sharp` <0.35.0 | high | CVE libvips; transitiva via `next` |
|
||||
| `brace-expansion` ≤5.0.7 | high | DoS, solo toolchain di sviluppo |
|
||||
| `js-yaml` 4.0.0–4.2.0 | high | DoS, solo dev |
|
||||
| `uuid` <11.1.1 | moderate | transitiva via `next-auth` |
|
||||
| `drizzle-kit` / `esbuild` | moderate | solo dev; il fix è un downgrade major → **non applicare** |
|
||||
|
||||
Totale `npm audit`: 12 vulnerabilità (1 critica, 5 alte, 5 moderate, 1 bassa).
|
||||
|
||||
---
|
||||
|
||||
## 6. MEDIO — Header di sicurezza incompleti
|
||||
|
||||
`next.config.ts` imposta `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`,
|
||||
`Permissions-Policy`. **Mancano** (verificato su risposta live):
|
||||
|
||||
- **`Strict-Transport-Security`** — assente. Il portale è interamente HTTPS; senza HSTS un
|
||||
downgrade attivo espone i token cliente, che viaggiano **nell'URL**.
|
||||
- **`Content-Security-Policy`** — assente. Nessuna mitigazione di secondo livello contro XSS.
|
||||
|
||||
---
|
||||
|
||||
## 7. BASSO — Ipotesi da confermare
|
||||
|
||||
- `ADMIN_PASSWORD` in `.env.local` è di 14 caratteri; `.env.example` prescrive "min 20 chars".
|
||||
È l'unico fattore di autenticazione admin (nessun 2FA, nessun lockout — vedi §8).
|
||||
- `rateLimit()` in `src/lib/rate-limit.ts` è in-memory e la `Map` **non viene mai potata**:
|
||||
cresce di una entry per IP distinto, senza limite → crescita di memoria non limitata.
|
||||
- `.dockerignore` esclude correttamente `.env` e `.env.local`: nessun segreto nell'immagine.
|
||||
- Nessuna credenziale trovata in file sorgente tracciati né nel resto della storia di git,
|
||||
oltre al caso §1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Nota di contesto — nessun lockout sul login admin
|
||||
|
||||
`src/lib/auth.ts` confronta email/password con le env var e non ha né rate limit né lockout
|
||||
(il `matcher` del proxy include `/admin/*` ma `/admin/login` è esplicitamente escluso dal guard,
|
||||
e `/api/auth/*` non è coperto dal rate limiter). Da correlare con l'esito dello scan del codice.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Piano di remediation sicurezza — ClientHub
|
||||
|
||||
Data: 2026-07-27 · Base: `.planning/SECURITY-SCAN.md` + `.planning/SECURITY-AUDIT-INFRA.md`
|
||||
Stato: **APPROVATO ed ESEGUITO per la parte codice (2026-07-27).**
|
||||
Restano aperti solo i punti che richiedono accesso a Coolify o una tua decisione — vedi in fondo.
|
||||
|
||||
11 findings totali: 2 critici, 4 alti, 3 medi, 2 bassi.
|
||||
|
||||
## Stato di esecuzione
|
||||
|
||||
| # | Intervento | Stato |
|
||||
|---|---|---|
|
||||
| 0.1 | Ruotare password Postgres prod | ⛔ **richiede te** (no accesso Coolify) |
|
||||
| 0.1b | Espurgare la password dai due `07-01-SUMMARY.md` | ✅ fatto |
|
||||
| 0.2 | `INTERNAL_SECRET` in Coolify | ⛔ **richiede te** |
|
||||
| 0.3 | Allungare `ADMIN_PASSWORD` | ⛔ **richiede te** |
|
||||
| 1.1 | Next → 16.2.12, next-auth → 4.24.15 | ✅ fatto |
|
||||
| 1.2 | Secondo gate di autenticazione admin | ✅ fatto |
|
||||
| 1.3 | Eliminare `src/lib/quote-actions.ts` | ✅ fatto |
|
||||
| 1.4 | Rate limit su `/client/` | ✅ fatto |
|
||||
| 2.1 | Slug a 12 caratteri + CSPRNG | ✅ fatto (solo nuovi clienti) |
|
||||
| 2.2 | Rigenerare gli slug esistenti | ⛔ **richiede tua decisione (a) o (b)** |
|
||||
| 3.1 | HSTS | ✅ fatto |
|
||||
| 3.2 | CSP | ✅ fatto (enforcing; `script-src` con `unsafe-inline`, motivato nel file) |
|
||||
| 3.3 | Rimuovere i 4 sink `dangerouslySetInnerHTML` | ✅ fatto |
|
||||
| 3.4 | Delimitare i transcript nel prompt | ✅ fatto |
|
||||
| 3.5 | Potatura della Map di `rate-limit.ts` | ✅ fatto |
|
||||
|
||||
Verifiche eseguite: `tsc --noEmit` pulito · `npm run build` OK (32 route) · `eslint` pulito sui file
|
||||
toccati · smoke test su `/admin/login` (rende), `/admin` (307 → login), header forgiati (307 → login,
|
||||
non servono la pagina) · CSP e HSTS presenti nella risposta.
|
||||
|
||||
`npm audit`: da 12 vulnerabilità (1 critica, 5 alte) a 10 (0 critiche, 5 alte). Le 9 CVE dirette di
|
||||
Next.js — incluso il proxy bypass — sono chiuse; il flag `next` residuo è solo transitivo via
|
||||
`postcss`/`sharp` vendorizzati dentro Next, e l'unico "fix" che npm propone è il downgrade a Next 9.
|
||||
`js-yaml` e `brace-expansion` restano ma sono solo toolchain di sviluppo.
|
||||
|
||||
---
|
||||
|
||||
## Ordine di esecuzione
|
||||
|
||||
Ordinato per *rischio ora*, non per difficoltà. I lotti 0 e 1 chiudono tutto ciò che è
|
||||
attivamente sfruttabile oggi.
|
||||
|
||||
---
|
||||
|
||||
### LOTTO 0 — Rotazione segreti (nessun codice, solo credenziali) · ~30 min
|
||||
|
||||
Va per primo perché è l'unico finding dove il segreto è **già uscito** dal perimetro.
|
||||
|
||||
**0.1 — Ruotare la password Postgres di produzione** (INFRA §1)
|
||||
- Nuova password su Postgres prod via `ssh` + `docker exec ... psql` (`ALTER USER clienthub WITH PASSWORD ...`)
|
||||
- Aggiornare `DATABASE_URL` nelle env var di Coolify → redeploy
|
||||
- Aggiornare `.env.local` locale
|
||||
- Espurgare la stringa dai due file `.planning/**/07-01-SUMMARY.md` e committare
|
||||
- ⚠️ La credenziale resta nella **storia** di git: la rotazione è ciò che la neutralizza,
|
||||
la cancellazione del file no. Non serve riscrivere la storia se la password è cambiata.
|
||||
|
||||
**0.2 — Impostare `INTERNAL_SECRET` in Coolify produzione** (INFRA §2)
|
||||
- Aggiungere la env var (valore già presente in `.env.local`, 44 char) → redeploy
|
||||
- **Verifica di accettazione:** `curl -sI 'https://hub.iamcavalli.net/api/internal/validate-slug?slug=x'`
|
||||
deve rispondere `403`, non `404`. Oggi risponde `404`.
|
||||
|
||||
**0.3 — Allungare `ADMIN_PASSWORD`** (INFRA §7) — oggi 14 char, il `.env.example` prescrive ≥20.
|
||||
È l'unico fattore di autenticazione admin.
|
||||
|
||||
> Rischio dati: **nullo**. Nessuna migrazione, nessuno schema toccato.
|
||||
|
||||
---
|
||||
|
||||
### LOTTO 1 — Chiudere lo sfruttabile · ~2 ore
|
||||
|
||||
**1.1 — Aggiornare Next.js a ≥ 16.2.11 e next-auth a ≥ 4.24.15** (INFRA §3, §4)
|
||||
- Chiude il proxy-bypass GHSA-6gpp-xcg3-4w24 (che è il moltiplicatore di C-1),
|
||||
la disclosure degli endpoint delle Server Function, 2 SSRF, 2 cache-confusion, 3 DoS,
|
||||
e il crash di `getToken()` su header `Authorization` malformato.
|
||||
- Update mirati, **non** `npm audit fix --force`: quel comando tenta di degradare
|
||||
`drizzle-kit` a 0.18.1 (downgrade major) e romperebbe le migrazioni.
|
||||
- Trascina anche i fix di `postcss` e `sharp`.
|
||||
- Serve un `npm run build` + smoke test su login admin e una dashboard cliente.
|
||||
|
||||
**1.2 — Rendere `admin/layout.tsx` un guard vero** (C-1) — una riga:
|
||||
```ts
|
||||
if (!session) redirect("/admin/login");
|
||||
```
|
||||
Da solo trasforma il singolo punto di rottura in due livelli indipendenti. Anche con il
|
||||
proxy aggiornato, questo è ciò che rende il sistema robusto al *prossimo* bug del proxy.
|
||||
|
||||
**1.3 — Eliminare `src/lib/quote-actions.ts`** (C-3) — codice morto, zero chiamanti,
|
||||
due endpoint pubblici non autenticati di cui uno scrive su DB. Cancellazione secca.
|
||||
In alternativa conservativa: `requireAdmin()` in testa a entrambe le funzioni.
|
||||
|
||||
**1.4 — Rate limit sul ramo `/client/`** in `src/proxy.ts` (C-2) — oggi il rate limiter è
|
||||
applicato solo a `/quote/`. Toglie la possibilità di brute-forzare gli slug a velocità utile.
|
||||
|
||||
> Rischio dati: **nullo**. Nessuna migrazione.
|
||||
|
||||
---
|
||||
|
||||
### LOTTO 2 — Rinforzare gli slug · ~2 ore · ⚠️ tocca dati esistenti
|
||||
|
||||
**2.1 — Portare il suffisso di `toSlug()` da 4 a 12 caratteri casuali** (C-2)
|
||||
- `src/app/admin/clients/new/actions.ts:22-30`
|
||||
- Da 36⁴ ≈ 1,7 milioni a 36¹² ≈ 4,7 × 10¹⁸.
|
||||
|
||||
**2.2 — Rigenerare gli slug dei clienti esistenti**
|
||||
- ⚠️ **Rompe i link già inviati ai clienti.** Da decidere insieme:
|
||||
- (a) rigenerare tutto e reinviare i link, oppure
|
||||
- (b) lasciare gli slug esistenti e affidarsi al rate limit di 1.4 come mitigazione.
|
||||
- Migrazione **puramente additiva** su una colonna esistente (`UPDATE clients SET slug=...`):
|
||||
nessun `DROP`, nessun `TRUNCATE`, conforme al vincolo Data Safety di CLAUDE.md.
|
||||
- **Questa è una decisione tua, non mia.** Il lotto 2 non parte senza una risposta su (a) vs (b).
|
||||
|
||||
---
|
||||
|
||||
### LOTTO 3 — Difesa in profondità · ~2 ore
|
||||
|
||||
**3.1 — `Strict-Transport-Security` in `next.config.ts`** (INFRA §6)
|
||||
`max-age=63072000; includeSubDomains; preload`. Conta più del normale qui: i token cliente
|
||||
viaggiano **nell'URL**, quindi un downgrade attivo li espone in chiaro.
|
||||
|
||||
**3.2 — `Content-Security-Policy`** (INFRA §6) — richiede un nonce per lo script inline di tema
|
||||
in `src/app/layout.tsx:38`. Da introdurre prima in `Report-Only` per una settimana, poi in enforcing.
|
||||
|
||||
**3.3 — Sanificare i quattro sink `dangerouslySetInnerHTML`** (C-4) in
|
||||
`src/components/public/proposal/sections/`. Dato che serve solo grassetto/corsivo, la strada
|
||||
più pulita non è aggiungere una libreria di sanitizzazione ma **togliere il rendering HTML**
|
||||
e sostituirlo con un formatter a whitelist di tag.
|
||||
|
||||
**3.4 — Delimitare i transcript nel prompt** di `src/lib/proposal/agent.ts:37-40`, così che
|
||||
il contenuto fornito da terzi non possa essere confuso con istruzioni.
|
||||
|
||||
**3.5 — Potare la `Map` di `src/lib/rate-limit.ts`** (INFRA §7) — oggi cresce di una entry per
|
||||
IP distinto e non viene mai svuotata: crescita di memoria non limitata nel container.
|
||||
|
||||
---
|
||||
|
||||
## Cosa NON propongo di fare
|
||||
|
||||
- **Non riscrivere la storia di git** per il leak §1: la rotazione neutralizza la credenziale,
|
||||
e un `filter-branch` su un repo con storia condivisa costa più di quanto renda.
|
||||
- **Non toccare `drizzle-kit` / `esbuild`**: le uniche vulnerabilità restanti sono di sola
|
||||
toolchain di sviluppo, non raggiungibili in produzione, e il "fix" è un downgrade major.
|
||||
- **Non introdurre 2FA sul login admin** in questo giro: è un cambio di prodotto, non una
|
||||
correzione. Da valutare insieme alla feature Email OTP già a design.
|
||||
|
||||
---
|
||||
|
||||
## Riepilogo
|
||||
|
||||
| Lotto | Contenuto | Tempo | Migrazioni | Rischio dati |
|
||||
|---|---|---|---|---|
|
||||
| 0 | Rotazione segreti | ~30 min | no | nullo |
|
||||
| 1 | Update + guard + dead code + rate limit | ~2 h | no | nullo |
|
||||
| 2 | Rinforzo slug | ~2 h | UPDATE additivo | **richiede tua decisione** |
|
||||
| 3 | HSTS, CSP, XSS, prompt, memoria | ~2 h | no | nullo |
|
||||
|
||||
I lotti 0 e 1 chiudono tutto ciò che è sfruttabile oggi e non toccano un solo dato.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Security Audit — Lane codice applicativo
|
||||
|
||||
Data: 2026-07-27 · Branch: `main` · Commit: `dd2d148`
|
||||
|
||||
> **Nota di metodo.** Lo scan multi-agente del plugin `claude-security` è stato avviato ma è
|
||||
> terminato in anticipo (limite di sessione API) dopo la sola fase di inventario, senza produrre
|
||||
> findings. Questo report è quindi il risultato di una revisione manuale mirata sullo stesso
|
||||
> perimetro. Copertura: server actions, modello di autorizzazione, IDOR sui link segreti, SQL
|
||||
> injection, XSS, prompt injection. Non è una revisione riga-per-riga di tutti i 185 file.
|
||||
|
||||
Lane complementare (dipendenze, segreti, deploy): `.planning/SECURITY-AUDIT-INFRA.md`.
|
||||
|
||||
---
|
||||
|
||||
## C-1 · CRITICO — L'autorizzazione admin è un unico punto di rottura, e quel punto ha una CVE
|
||||
|
||||
**Evidenza.** Nessuna delle **21** pagine sotto `src/app/admin/**/page.tsx` esegue un proprio
|
||||
controllo di sessione. Verificato con grep su `getServerSession` / `redirect("/admin/login")`:
|
||||
tutte a zero.
|
||||
|
||||
E `src/app/admin/layout.tsx` **non è un guard** — legge la sessione ma, se manca, renderizza
|
||||
comunque i figli:
|
||||
|
||||
```ts
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>; // ← rende comunque
|
||||
}
|
||||
```
|
||||
|
||||
Senza sessione la pagina admin viene servita lo stesso, solo senza la chrome di `AdminShell`.
|
||||
|
||||
**Conseguenza.** L'unico controllo effettivo su `/admin/*` è il redirect in `src/proxy.ts`.
|
||||
Non esiste difesa in profondità.
|
||||
|
||||
**Perché ora è critico e non solo fragile:** `next@16.2.6` è affetto da
|
||||
**GHSA-6gpp-xcg3-4w24 — Middleware/Proxy bypass in App Router** (fix in 16.2.11). Un bypass del
|
||||
proxy espone *tutte* le pagine admin — anagrafica clienti, pagamenti, preventivi, marginalità —
|
||||
senza alcun secondo controllo che le fermi.
|
||||
|
||||
**Fix.** Due interventi, entrambi necessari:
|
||||
1. Aggiornare Next a ≥ 16.2.11 (chiude la CVE).
|
||||
2. Rendere `admin/layout.tsx` un guard vero: `if (!session) redirect("/admin/login")`.
|
||||
È una riga, e trasforma il singolo punto di rottura in due livelli indipendenti.
|
||||
|
||||
---
|
||||
|
||||
## C-2 · ALTO — Gli slug cliente hanno solo ~4 caratteri di casualità, e nulla li protegge dal brute force
|
||||
|
||||
**Evidenza.** `src/app/admin/clients/new/actions.ts:17-30`:
|
||||
|
||||
```ts
|
||||
function randomAlpha(len: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 simboli
|
||||
...
|
||||
}
|
||||
function toSlug(name: string): string {
|
||||
const base = name.toLowerCase()...; // ← il nome del cliente, indovinabile
|
||||
return `${base}-${randomAlpha(4)}`; // ← solo 4 caratteri casuali
|
||||
}
|
||||
```
|
||||
|
||||
Lo spazio di ricerca è **36⁴ = 1.679.616** combinazioni, con il prefisso noto o deducibile
|
||||
(è il nome del cliente o del brand).
|
||||
|
||||
**Perché conta.** `src/lib/client-view.ts:163-193` risolve **prima lo slug, poi il token**
|
||||
(scelta D-06). Lo slug è quindi una via d'accesso *parallela e piena* alla dashboard cliente,
|
||||
equivalente al token — ma il token è `nanoid(21)` (~122 bit), lo slug ~20,7 bit. La sicurezza
|
||||
del sistema è quella dell'anello debole.
|
||||
|
||||
**Aggravanti — non c'è niente che rallenti il tentativo:**
|
||||
- Il `matcher` di `src/proxy.ts` copre `/client/:path*`, ma il rate limiter viene applicato
|
||||
**solo** al ramo `/quote/[token]`. Il ramo `/client/` non è limitato.
|
||||
- `/api/internal/validate-slug` è pubblico e non limitato (vedi INFRA §2): è un oracolo che
|
||||
risponde `200`/`404` senza nemmeno dover caricare la pagina.
|
||||
|
||||
**Fix.** Portare il suffisso casuale ad almeno 10-12 caratteri per i nuovi clienti, applicare
|
||||
`rateLimit()` anche al ramo `/client/` del proxy, e rigenerare gli slug esistenti.
|
||||
|
||||
---
|
||||
|
||||
## C-3 · ALTO — `src/lib/quote-actions.ts`: due server action pubbliche senza autenticazione
|
||||
|
||||
**Evidenza.** Il file inizia con `"use server"` ed esporta due funzioni. Nessuna delle due
|
||||
chiama `getServerSession`, e il file non importa affatto `authOptions`:
|
||||
|
||||
- `getOfferWithPhases(offerMicroId)` — legge offerta + fasi + prezzi
|
||||
- `createQuote(input)` — **scrive** una riga in `quotes` per un qualunque `client_id`
|
||||
|
||||
Ogni export in un file `"use server"` diventa un endpoint HTTP pubblico. Il resto della codebase
|
||||
è coerente e corretto — `src/app/admin/**/actions.ts` definisce e usa `requireAdmin()` ovunque
|
||||
(es. `clients/new/actions.ts:12-15`) — **questi due sono l'eccezione**.
|
||||
|
||||
**Aggravante.** `createQuote` e `getOfferWithPhases` non hanno **alcun chiamante** nella codebase
|
||||
(verificato con grep sull'intero `src/`). Sono codice morto — ma codice morto *raggiungibile*:
|
||||
Next.js li compila comunque come endpoint. Superficie d'attacco a costo zero e beneficio zero.
|
||||
|
||||
**Fix.** Cancellare il file. Se serve tenerlo, aggiungere `requireAdmin()` in testa a entrambe.
|
||||
|
||||
---
|
||||
|
||||
## C-4 · MEDIO — XSS immagazzinato: contenuto generato dall'AI renderizzato come HTML grezzo
|
||||
|
||||
**Evidenza.** Quattro sink su pagina pubblica:
|
||||
|
||||
| File | Riga |
|
||||
|---|---|
|
||||
| `src/components/public/proposal/sections/StrategistSection.tsx` | 36 |
|
||||
| `src/components/public/proposal/sections/ScopeSection.tsx` | 31 |
|
||||
| `src/components/public/proposal/sections/SolutionNodeSection.tsx` | 28 |
|
||||
| `src/components/public/proposal/sections/DeliverablesSection.tsx` | 21 |
|
||||
|
||||
```tsx
|
||||
<span dangerouslySetInnerHTML={{ __html: obj }} />
|
||||
```
|
||||
|
||||
`obj` viene da `ProposalContent` (`src/lib/proposal/schema.ts`), cioè dall'output di Claude in
|
||||
`src/lib/proposal/agent.ts:131-135`. Lo schema Zod valida la *struttura*, non il *contenuto*:
|
||||
una stringa con `<img src=x onerror=...>` passa la validazione.
|
||||
|
||||
**Catena di attacco.** `agent.ts:37-40` interpola i transcript nel prompt utente senza
|
||||
delimitazione né sanificazione:
|
||||
|
||||
```ts
|
||||
`=== TRANSCRIPT ${i + 1} — ${t.call_date} ... ===\n${t.content}`
|
||||
```
|
||||
|
||||
Un prospect che invia testo che finisce in un transcript può tentare una prompt injection per far
|
||||
emettere all'AI markup attivo, che viene poi salvato e servito come HTML su `/preventivo/[slug]`.
|
||||
|
||||
**Attenuanti (per cui è MEDIO e non ALTO):** `ProposalDeck` è renderizzato **solo** sulla pagina
|
||||
pubblica, mai in `/admin` — la vittima è il destinatario della proposta, non l'admin, quindi non
|
||||
c'è furto di sessione admin. E richiede che la prompt injection vada a segno.
|
||||
|
||||
**Fix.** Sanificare a monte del render, o — più semplice, dato che serve solo grassetto/corsivo —
|
||||
sostituire il rendering HTML con un piccolo formatter che accetta una whitelist di tag.
|
||||
|
||||
---
|
||||
|
||||
## Cosa invece è risultato SOLIDO
|
||||
|
||||
Vale la pena dirlo esplicitamente, perché è la maggior parte del codice.
|
||||
|
||||
- **SQL injection: nessuna.** Tutti i 17 usi di `` sql`...` `` interpolano riferimenti a colonne
|
||||
Drizzle o valori parametrizzati. `sql.raw` e `db.execute` non compaiono mai nella codebase.
|
||||
|
||||
- **Autorizzazione delle server action admin: corretta e sistematica.** 78 azioni su 80 in
|
||||
`src/app/admin/**` passano da `requireAdmin()`. Le uniche due eccezioni sono in C-3.
|
||||
|
||||
- **Le server action inline nei componenti sono sicure.** Le closure `"use server"` in
|
||||
`PhasesTab.tsx` (4), `DocumentsTab.tsx` (1) e `CommentsTab.tsx` (1) sembrano prive di
|
||||
controlli, ma sono wrapper sottili che delegano ad azioni di
|
||||
`src/app/admin/clients/[id]/actions.ts`, dove `requireAdmin()` c'è. Falso positivo.
|
||||
|
||||
- **IDOR sulla dashboard cliente: assente.** `getProjectView()` è invocato solo con id di progetto
|
||||
già ricavati dal cliente risolto (`page.tsx:120,132`), mai da input utente. La route
|
||||
`/client/[token]` non legge `searchParams`.
|
||||
|
||||
- **Il vincolo `quote_items` di CLAUDE.md è rispettato.** `quote_items` compare solo in
|
||||
`admin-queries.ts`, `quote-service.ts` e `quote-actions.ts`. `src/lib/client-view.ts` — l'unico
|
||||
percorso dati verso il cliente — non lo tocca (commento esplicito alle righe 8 e 217).
|
||||
|
||||
- **Immutabilità di `approved_at`: rispettata**, e in due punti indipendenti.
|
||||
`api/client/approve/route.ts` ritorna un no-op se già valorizzato, e
|
||||
`preventivo/[slug]/actions.ts` usa `isNull(proposals.accepted_at)` nella `WHERE` dell'UPDATE —
|
||||
guard atomico lato DB, resistente alle race condition. Buona ingegneria.
|
||||
|
||||
- **Scoping degli endpoint cliente: corretto.** `api/client/comment/route.ts` verifica la
|
||||
proprietà risalendo la catena client → projects → phases → tasks → deliverables prima di
|
||||
ogni insert. `api/client/approve/route.ts` fa lo stesso con una `innerJoin` che vincola
|
||||
`projects.client_id`. Entrambi sono rate-limitati.
|
||||
|
||||
- **I token sono forti.** `clients.token`, `quotes.token` e `proposals.slug` usano tutti
|
||||
`nanoid(21)` (~122 bit). Il problema è solo lo slug cliente (C-2), che è generato diversamente.
|
||||
|
||||
- **`clients.token` è un campo separato e ruotabile**, mai primary key — vincolo LOCKED rispettato.
|
||||
@@ -185,21 +185,21 @@ When the Postgres database is reachable:
|
||||
|
||||
1. **Apply schema migration:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/push-services-migration.ts
|
||||
```
|
||||
This creates the `services` table in production.
|
||||
|
||||
2. **Run backfill:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/migrate-services.ts
|
||||
```
|
||||
Migrates 21 rows from service_catalog + 35 rows from offer_services.
|
||||
|
||||
3. **Validate migration:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/validate-services-migration.ts
|
||||
```
|
||||
All checks must print PASS.
|
||||
|
||||
@@ -185,21 +185,21 @@ When the Postgres database is reachable:
|
||||
|
||||
1. **Apply schema migration:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/push-services-migration.ts
|
||||
```
|
||||
This creates the `services` table in production.
|
||||
|
||||
2. **Run backfill:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/migrate-services.ts
|
||||
```
|
||||
Migrates 21 rows from service_catalog + 35 rows from offer_services.
|
||||
|
||||
3. **Validate migration:**
|
||||
```bash
|
||||
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
|
||||
npx tsx scripts/validate-services-migration.ts
|
||||
```
|
||||
All checks must print PASS.
|
||||
|
||||
Reference in New Issue
Block a user