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;