Files
clienthub/.planning/SECURITY-AUDIT-INFRA.md
T
simone 94b3b2f766 chore: riorganizzazione e pulizia della cartella di progetto
Rimossi i doppioni e gli artefatti accumulati, senza cancellare nulla di
definitivo: tutto cio' che serviva una revisione e' parcheggiato in cestino/
(gitignored), documentato in cestino/LEGGIMI.md.

- .planning/phases/01-10: 10 cartelle identiche byte-per-byte alle copie in
  .planning/milestones/v1.0-phases e v2.0-phases. HANDOFF.md:33 documentava che
  furono copiate e non spostate, lasciando la pulizia 'facoltativa in futuro'.
  Verificata l'identita' con diff -rq prima di spostare ciascuna.
- scripts/: 13 script one-off gia' eseguiti (push-*, migrate-*, validate-*,
  verify-12-03-*) piu' reset-and-import-services.ts, che cancella dati.
  Restano i 3 riutilizzabili: seed, import-services-notion, import-service-offer-tags.
- CLAUDE-SECURITY-20260727-210226/: cartella di lavoro della run interrotta.

Cancellati subito, senza revisione: 6 .DS_Store, le due cache .impeccable/
(una era dentro src/) e tsconfig.tsbuildinfo.

.gitignore: aggiunti cestino/, .impeccable/ e CLAUDE-SECURITY-*/ per evitare
che si riformino.

src/ non e' stato toccato: la struttura e' dettata dall'App Router di Next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:13:32 +02:00

169 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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~~ → BASSO — Password Postgres **storica** committata in chiaro
> **CORREZIONE (2026-07-28).** La prima stesura di questa sezione dichiarava la credenziale
> "ancora attiva". **Era sbagliato.** La verifica iniziale si limitava a constatare che la
> stringa comparisse anche in `.env.local`, e ne deduceva che fosse quella viva.
>
> Verifica corretta, fatta poi: confronto del **verifier SCRAM-SHA-256** di `pg_authid` contro
> i due candidati. La password committata **non** combacia; quella in uso è la password da 48
> caratteri configurata su Coolify. Era già stata ruotata in passato.
>
> La voce `DATABASE_URL` porta 5432 di `.env.local` è **stale** — residuo storico, non la
> connessione di produzione (che passa dall'hostname interno del container).
>
> **Severità reale: BASSA.** Credenziale storica in un repo privato, già morta. Igiene, non
> incidente. Nessuna rotazione urgente. Resta utile ripulire `.env.local` dalla voce stale.
>
> Il resto della sezione è conservato come traccia di ciò che è stato trovato.
**Dove:**
- `.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md:188,195,202`
- (esisteva anche in `.planning/phases/07-unified-service-catalog/`, cartella duplicata
spostata in `cestino/` il 2026-07-28 durante la pulizia — la stringa era già stata espunta
da entrambe le copie)
**Cosa:** stringa completa in plaintext, dentro git, con credenziale reale:
```
postgresql://clienthub:<PASSWORD-IN-CHIARO>@178.104.27.55:5432/clienthub?sslmode=disable
```
**Verificato (versione corretta):** la password presente in quei file **non è più attiva**
il verifier SCRAM del DB non combacia. Coincideva solo con la voce `DATABASE_URL` porta 5432
di `.env.local`, che è a sua volta stale. Era già stata ruotata prima di questo audit.
**Aggravanti — valgono solo per il periodo in cui la credenziale era viva:**
- `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.04.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.