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:
2026-07-27 23:35:36 +02:00
parent dd2d148457
commit e2bd1d95ed
20 changed files with 768 additions and 189 deletions
+30 -2
View File
@@ -1,18 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { rateLimit } from "@/lib/rate-limit";
import {
ADMIN_GATE_HEADER,
ADMIN_PATHNAME_HEADER,
adminGateToken,
} from "@/lib/admin-gate";
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
if (pathname.startsWith("/admin")) {
// Stamp the path so src/app/admin/layout.tsx can run its own session check
// and still tell /admin/login apart, plus a secret-derived token proving
// this proxy ran. set() overwrites any client-supplied value; if the proxy
// is bypassed entirely neither header is valid and the layout fails closed.
const withPath = new Headers(request.headers);
withPath.set(ADMIN_PATHNAME_HEADER, pathname);
withPath.set(ADMIN_GATE_HEADER, await adminGateToken());
const forward = { request: { headers: withPath } };
// Allow the login page and NextAuth API routes through without session check
if (
pathname === "/admin/login" ||
pathname.startsWith("/api/auth")
) {
return NextResponse.next();
return NextResponse.next(forward);
}
const token = await getToken({
@@ -26,7 +40,7 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
return NextResponse.next(forward);
}
// ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
@@ -36,6 +50,20 @@ export async function proxy(request: NextRequest) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
// Client slugs carry far less entropy than the 21-char nanoid tokens
// (see C-2 in .planning/SECURITY-SCAN.md), so this path must not be
// brute-forceable at speed. Was previously applied only to /quote.
const clientIp =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
if (!rateLimit(`client:${clientIp}`, 20, 60 * 1000)) {
return NextResponse.json(
{ error: "Troppi accessi. Riprova tra un minuto." },
{ status: 429 }
);
}
const slugOrToken = slugOrTokenMatch[1];
try {