diff --git a/.planning/SECURITY-AUDIT-INFRA.md b/.planning/SECURITY-AUDIT-INFRA.md new file mode 100644 index 0000000..9fc834d --- /dev/null +++ b/.planning/SECURITY-AUDIT-INFRA.md @@ -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:@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=` 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. diff --git a/.planning/SECURITY-REMEDIATION-PLAN.md b/.planning/SECURITY-REMEDIATION-PLAN.md new file mode 100644 index 0000000..8932702 --- /dev/null +++ b/.planning/SECURITY-REMEDIATION-PLAN.md @@ -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. diff --git a/.planning/SECURITY-SCAN.md b/.planning/SECURITY-SCAN.md new file mode 100644 index 0000000..788e0c8 --- /dev/null +++ b/.planning/SECURITY-SCAN.md @@ -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
{children}
; // ← 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 + +``` + +`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 `` 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. diff --git a/.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md b/.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md index adf3c80..47331d4 100644 --- a/.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md +++ b/.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md @@ -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. diff --git a/.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md b/.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md index adf3c80..47331d4 100644 --- a/.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md +++ b/.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md @@ -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. diff --git a/next.config.ts b/next.config.ts index 5cf8710..86a14b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,11 +1,43 @@ import type { NextConfig } from "next"; +// Enforcing, but deliberately permissive on script-src. +// +// A nonce-based script-src would mean reading headers() in the root layout, +// which opts every route out of static rendering — today `/` is served from +// cache with s-maxage=31536000. That trade is not worth it while the only +// inline script is the theme FOUC guard in src/app/layout.tsx and the XSS sink +// it would defend has already been removed (C-4 in .planning/SECURITY-SCAN.md). +// +// The remaining directives cost nothing and still close real avenues: no +// plugins, no hijacking, no framing, no posting form data off-site, and +// no exfiltration channel to an arbitrary host. +const contentSecurityPolicy = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", +].join("; "); + const securityHeaders = [ { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "X-DNS-Prefetch-Control", value: "on" }, + // Client dashboard tokens travel in the URL path, so an active downgrade + // would expose them in cleartext — HSTS matters more here than usual. + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { key: "Content-Security-Policy", value: contentSecurityPolicy }, ]; const nextConfig: NextConfig = { diff --git a/package-lock.json b/package-lock.json index 8a78af0..58efadb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,8 +26,8 @@ "drizzle-orm": "^0.45.2", "lucide-react": "^1.14.0", "nanoid": "^5.1.11", - "next": "16.2.6", - "next-auth": "^4.24.14", + "next": "^16.2.12", + "next-auth": "^4.24.15", "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", @@ -2140,9 +2140,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", - "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2156,9 +2156,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", - "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "cpu": [ "arm64" ], @@ -2172,9 +2172,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", - "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "cpu": [ "x64" ], @@ -2188,9 +2188,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", - "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "cpu": [ "arm64" ], @@ -2207,9 +2207,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", - "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "cpu": [ "arm64" ], @@ -2226,9 +2226,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", - "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "cpu": [ "x64" ], @@ -2245,9 +2245,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", - "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "cpu": [ "x64" ], @@ -2264,9 +2264,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", - "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "cpu": [ "arm64" ], @@ -2280,9 +2280,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", - "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "cpu": [ "x64" ], @@ -7921,12 +7921,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", - "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.6", + "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -7940,14 +7940,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.6", - "@next/swc-darwin-x64": "16.2.6", - "@next/swc-linux-arm64-gnu": "16.2.6", - "@next/swc-linux-arm64-musl": "16.2.6", - "@next/swc-linux-x64-gnu": "16.2.6", - "@next/swc-linux-x64-musl": "16.2.6", - "@next/swc-win32-arm64-msvc": "16.2.6", - "@next/swc-win32-x64-msvc": "16.2.6", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { @@ -7974,9 +7974,9 @@ } }, "node_modules/next-auth": { - "version": "4.24.14", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", - "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "version": "4.24.15", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.15.tgz", + "integrity": "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA==", "license": "ISC", "dependencies": { "@babel/runtime": "^7.20.13", @@ -7987,7 +7987,7 @@ "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", - "uuid": "^8.3.2" + "uuid": "^11.1.1" }, "peerDependencies": { "@auth/core": "0.34.3", @@ -10225,13 +10225,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/which": { diff --git a/package.json b/package.json index 1da08b7..625ecb0 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "drizzle-orm": "^0.45.2", "lucide-react": "^1.14.0", "nanoid": "^5.1.11", - "next": "16.2.6", - "next-auth": "^4.24.14", + "next": "^16.2.12", + "next-auth": "^4.24.15", "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/app/admin/clients/new/actions.ts b/src/app/admin/clients/new/actions.ts index 6bb651f..34d1b84 100644 --- a/src/app/admin/clients/new/actions.ts +++ b/src/app/admin/clients/new/actions.ts @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { eq } from "drizzle-orm"; +import { customAlphabet } from "nanoid"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { db } from "@/db"; @@ -14,10 +15,12 @@ async function requireAdmin() { if (!session) throw new Error("Non autorizzato"); } -function randomAlpha(len: number): string { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); -} +// The slug is a full access path to the client dashboard, resolved before the +// token (D-06) — so it needs token-grade entropy, not a readability suffix. +// 12 chars over a 36-symbol alphabet ≈ 62 bits; the old 4 chars were ≈ 20 bits, +// i.e. 1.7M guesses against an endpoint that had no rate limit (C-2). +// customAlphabet is CSPRNG-backed, unlike Math.random(). +const randomAlpha = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 12); function toSlug(name: string): string { const base = name @@ -27,7 +30,7 @@ function toSlug(name: string): string { .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 44); - return `${base}-${randomAlpha(4)}`; + return `${base}-${randomAlpha()}`; } async function uniqueSlug(base: string): Promise { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index eb177b6..61667c6 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,6 +1,14 @@ import { AdminShell } from "@/components/admin/AdminShell"; import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { authOptions } from "@/lib/auth"; +import { + ADMIN_GATE_HEADER, + ADMIN_PATHNAME_HEADER, + adminGateToken, + safeEqual, +} from "@/lib/admin-gate"; import { getUnreadConversationsCount } from "@/lib/conversations-queries"; export default async function AdminLayout({ @@ -10,7 +18,32 @@ export default async function AdminLayout({ }) { const session = await getServerSession(authOptions); if (!session) { - return
{children}
; + // Second, independent auth gate. proxy.ts already redirects unauthenticated + // /admin traffic, but no admin page checks the session on its own — without + // this, a proxy bypass would serve every admin page in full. + // + // proxy.ts stamps the path on every /admin request, so we can tell + // /admin/login (which must render without a session) from everything else, + // plus a secret-derived token so the path cannot be forged by a client. + // No valid token means the proxy never ran: fail closed, and render rather + // than redirect so a bypass cannot turn into a redirect loop. + const h = await headers(); + const trusted = safeEqual(h.get(ADMIN_GATE_HEADER), await adminGateToken()); + const pathname = trusted ? h.get(ADMIN_PATHNAME_HEADER) : null; + + if (pathname === null) { + return ( +
+

Sessione non valida.

+
+ ); + } + + if (pathname === "/admin/login") { + return
{children}
; + } + + redirect("/admin/login"); } const unreadConversations = await getUnreadConversationsCount(); return {children}; diff --git a/src/components/public/proposal/RichText.tsx b/src/components/public/proposal/RichText.tsx new file mode 100644 index 0000000..7b05726 --- /dev/null +++ b/src/components/public/proposal/RichText.tsx @@ -0,0 +1,43 @@ +import { Fragment } from "react"; + +/** + * Renders AI-generated proposal strings with emphasis, without ever handing raw + * HTML to the DOM. + * + * These strings come from Claude (src/lib/proposal/agent.ts), which builds its + * prompt from client transcripts — third-party text. They were previously + * rendered with dangerouslySetInnerHTML on the public /preventivo/[slug] page, + * so a successful prompt injection became stored XSS against the prospect + * (C-4 in .planning/SECURITY-SCAN.md). The prompt never asks for HTML in the + * first place; the only markup worth keeping is emphasis. + * + * Recognises **bold** and / and turns them into real React elements. + * Anything else — including ,