fix: admin panel resiste a fallimento cookie CSRF
Local Build and Deploy / deploy (push) Failing after 34s

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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-18 19:48:30 +02:00
1 parent 2de3696993
commit 557138e40b
2 files changed
+98 -22

No files matched your search

+9 -2
View File
@@ -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 (
<>
<meta name="csrf-token" content={csrfToken} />
{csrfToken ? (
<meta name="csrf-token" content={csrfToken} />
) : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
data-admin
+89 -20
View File
@@ -6,12 +6,46 @@ import type { IpAddress } from "./types";
const CSRF_BYTES = 32;
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
const CSRF_COOKIE_FALLBACK = "csrf-token";
const CSRF_COOKIE_HOST = "__Host-csrf-token";
/** `__Host-` requires Secure; use a plain name on non-HTTPS local dev. */
function csrfCookieName(): string {
return process.env.NODE_ENV === "production"
? "__Host-csrf-token"
: "csrf-token";
const CSRF_COOKIE_NAMES = [CSRF_COOKIE_HOST, CSRF_COOKIE_FALLBACK] as const;
/** HTTPS from reverse proxy (Traefik) or configured APP_URL. */
async function isRequestSecure(): Promise<boolean> {
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<ReturnType<typeof cookies>>,
): 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<string> = 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<ReturnType<typeof cookies>>,
opts: ReturnType<typeof csrfCookieOpts>,
): 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<string> {
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<boolean> {
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;