Files
clienthub/.planning/SECURITY-AUDIT-INFRA.md
T
simone fb6ab92fd0 docs(security): correzione severita finding 1 + chiusura punti Coolify
CORREZIONE: la password Postgres trovata in git NON era attiva. La verifica
iniziale si limitava a constatare che la stringa comparisse in .env.local e ne
deduceva che fosse quella viva. Il confronto del verifier SCRAM-SHA-256 di
pg_authid contro i due candidati mostra che quella committata non combacia:
era gia stata ruotata. La voce DATABASE_URL porta 5432 di .env.local e' stale.
Severita' reale: BASSA, non CRITICA. Nessuna rotazione necessaria.

Chiusi via API Coolify: INTERNAL_SECRET creata (le route /api/internal/*
rispondono ora 403 invece di 404, oracolo di enumerazione token chiuso) e
ADMIN_PASSWORD portata da 15 a 32 caratteri. Redeploy verificato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:06:48 +02:00

166 lines
7.7 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/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.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.