f7eb7eec23
.planning/ documentava in dettaglio cio che era vecchio e per niente cio
che e in produzione: le fasi 11-22 (v2.1 e v2.2, chiuse a giugno) erano
ancora in phases/ mentre v1.0 e v2.0 stavano gia in milestones/, e il
lavoro degli ultimi due mesi - gate OTP e ciclo di vita dei retainer, cioe
quello che gira su hub.iamcavalli.net - non aveva nessuna cartella.
- phases/{11,12,14} -> milestones/v2.1-phases/, phases/{18..22} ->
milestones/v2.2-phases/. Ora phases/ contiene solo la milestone in
corso, che e quello che state.cjs conta per il progresso
- v2.1-ROADMAP.md ricostruito: era l'unica milestone senza archivio,
interrotta dal reset del 19/06 e mai chiusa formalmente
- v2.3-ROADMAP.md + v2.3-REQUIREMENTS.md: v2.3 e stata eseguita fuori dal
ciclo GSD, non esistono PLAN/SUMMARY per fase. L'archivio E la doc
- REQUIREMENTS.md riscritto per v2.4 con il backlog reale
- phases/13 e phases/26: SUMMARY ricostruiti da commit, migration e
STATUS.md. 26 e il primo numero libero
- research/: cancellate 4 varianti dello stesso PITFALLS e FEATURES/
SUMMARY, superati da PROJECT.md. Diverse anti-feature erano ormai
contraddette dai fatti (il Kanban e stato costruito in Phase 19,
l'email in v2.3, il time tracking esiste)
- cancellati UI-RULES.md e DESIGN-SYSTEM.md (CLAUDE.md li dichiara
superseded: impongono l'inverso della regola attuale) e HANDOFF.md,
fermo al 13/06
- SECURITY-*.md -> security/: audit chiuso, ma i report restano la doc di
cosa e stato ruotato e perche
- PROJECT.md/MILESTONES.md/ROADMAP.md allineati: milestone corrente v2.4,
sessione OTP 90gg non 30, migrazioni fino alla 0016
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
8.0 KiB
Markdown
169 lines
8.0 KiB
Markdown
# 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.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.
|