From 557138e40bda54403c31f2e1fa7c46c09509772e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 18 Jul 2026 19:48:30 +0200 Subject: [PATCH] fix: admin panel resiste a fallimento cookie CSRF Il layout admin non deve crashare se cookies().set() fallisce (__Host-/Secure dietro proxy). Fallback su csrf-token, meta solo con token valido. Co-authored-by: Cursor --- src/app/admin/layout.tsx | 11 +++- src/lib/foundation/security.ts | 109 +++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 59b8055d..cf1ece4c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -23,7 +23,12 @@ export default async function AdminLayout({ children: ReactNode; }) { const staff = await requireStaff(); - const csrfToken = await setCsrfCookie(); + let csrfToken = ""; + try { + csrfToken = await setCsrfCookie(); + } catch { + csrfToken = ""; + } if (await siteSettings.getBool("force_staff_2fa", false)) { const u = await prisma.user .findUnique({ @@ -36,7 +41,9 @@ export default async function AdminLayout({ return ( <> - + {csrfToken ? ( + + ) : null} }>
{ + try { + const h = await headers(); + const proto = h + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + .toLowerCase(); + if (proto === "https") return true; + if (proto === "http") return false; + } catch { + // headers unavailable during static analysis + } + try { + if (env.APP_URL) return new URL(env.APP_URL).protocol === "https:"; + } catch { + // ignore malformed APP_URL + } + return process.env.NODE_ENV === "production"; +} + +function preferredCsrfCookieName(secure: boolean): string { + return secure ? CSRF_COOKIE_HOST : CSRF_COOKIE_FALLBACK; +} + +function readExistingCsrfCookie( + c: Awaited>, +): string | undefined { + for (const name of CSRF_COOKIE_NAMES) { + const existing = c.get(name); + if (existing?.value && existing.value.length === CSRF_BYTES * 2) + return existing.value; + } + return undefined; } const ALLOWED_HOSTS: ReadonlySet = new Set( @@ -64,7 +98,11 @@ export function redirectSafe( redirect(safeRedirect(destination, fallback)); } -function csrfCookieOpts(value: string): { +function csrfCookieOpts( + name: string, + value: string, + secure: boolean, +): { name: string; value: string; httpOnly: boolean; @@ -73,36 +111,67 @@ function csrfCookieOpts(value: string): { path: string; maxAge: number; } { - const isProd = process.env.NODE_ENV === "production"; return { - name: csrfCookieName(), + name, value, httpOnly: true, - secure: isProd, + secure, sameSite: "lax" as const, path: "/", maxAge: CSRF_COOKIE_MAX_AGE, }; } +function trySetCsrfCookie( + c: Awaited>, + opts: ReturnType, +): boolean { + try { + c.set(opts.name, opts.value, opts); + return true; + } catch { + return false; + } +} + +/** Sets the CSRF cookie when possible; returns token or empty string (never throws). */ export async function setCsrfCookie(): Promise { - const c = await cookies(); - const name = csrfCookieName(); - const existing = c.get(name); - if (existing?.value && existing.value.length === CSRF_BYTES * 2) - return existing.value; - const value = crypto.randomBytes(CSRF_BYTES).toString("hex"); - const opts = csrfCookieOpts(value); - c.set(opts.name, opts.value, opts); - return value; + try { + const c = await cookies(); + const existing = readExistingCsrfCookie(c); + if (existing) return existing; + + const value = crypto.randomBytes(CSRF_BYTES).toString("hex"); + const secure = await isRequestSecure(); + const primary = csrfCookieOpts( + preferredCsrfCookieName(secure), + value, + secure, + ); + if (trySetCsrfCookie(c, primary)) return value; + + const fallback = csrfCookieOpts(CSRF_COOKIE_FALLBACK, value, secure); + if (trySetCsrfCookie(c, fallback)) return value; + + return ""; + } catch { + return ""; + } } export async function validateCsrfToken(token: string): Promise { if (!token || token.length !== CSRF_BYTES * 2) return false; try { const c = await cookies(); - const stored = c.get(csrfCookieName())?.value; - if (!stored || stored.length !== CSRF_BYTES * 2) return false; + let stored: string | undefined; + for (const name of CSRF_COOKIE_NAMES) { + const candidate = c.get(name)?.value; + if (candidate && candidate.length === CSRF_BYTES * 2) { + stored = candidate; + break; + } + } + if (!stored) return false; return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored)); } catch { return false;