e2bd1d95ed
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>
122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
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(forward);
|
|
}
|
|
|
|
const token = await getToken({
|
|
req: request,
|
|
secret: process.env.NEXTAUTH_SECRET,
|
|
});
|
|
|
|
if (!token) {
|
|
const loginUrl = new URL("/admin/login", request.url);
|
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
return NextResponse.next(forward);
|
|
}
|
|
|
|
// ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
|
|
if (pathname.startsWith("/client/")) {
|
|
const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
|
|
if (!slugOrTokenMatch) {
|
|
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 {
|
|
// Use localhost to avoid hairpin NAT issues in Docker.
|
|
// request.url is the external hostname (via Traefik); inside the container the app is on localhost.
|
|
const port = process.env.PORT ?? "3000";
|
|
const base = `http://localhost:${port}`;
|
|
|
|
const internalHeaders = {
|
|
"x-internal-secret": process.env.INTERNAL_SECRET ?? "",
|
|
};
|
|
|
|
// Try slug first (D-06) — user-friendly slugs before token fallback
|
|
let res = await fetch(
|
|
`${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
|
{ headers: internalHeaders }
|
|
);
|
|
|
|
// If slug not found, fall back to token validation (existing links continue to work)
|
|
if (!res.ok) {
|
|
res = await fetch(
|
|
`${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
|
|
{ headers: internalHeaders }
|
|
);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
|
}
|
|
|
|
return NextResponse.next();
|
|
} catch {
|
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
|
}
|
|
}
|
|
|
|
// ── PUBLIC QUOTE ROUTES (rate limited) ───────────────────────────────────
|
|
if (pathname.match(/^\/quote\/[a-zA-Z0-9_-]{21}\/?$/)) {
|
|
const ip = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown";
|
|
const allowed = rateLimit(ip, 3, 60 * 1000); // 3 views per minute
|
|
|
|
if (!allowed) {
|
|
return NextResponse.json(
|
|
{ error: "Troppi accessi. Riprova tra un minuto." },
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/admin/:path*", "/client/:path*", "/quote/:path*"],
|
|
};
|