fix: admin panel resiste a fallimento cookie CSRF
Local Build and Deploy / deploy (push) Failing after 34s
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:
1 parent
2de3696993
commit
557138e40b
2 files changed
+98
-22
No files matched your search
@@ -23,7 +23,12 @@ export default async function AdminLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requireStaff();
|
const staff = await requireStaff();
|
||||||
const csrfToken = await setCsrfCookie();
|
let csrfToken = "";
|
||||||
|
try {
|
||||||
|
csrfToken = await setCsrfCookie();
|
||||||
|
} catch {
|
||||||
|
csrfToken = "";
|
||||||
|
}
|
||||||
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
if (await siteSettings.getBool("force_staff_2fa", false)) {
|
||||||
const u = await prisma.user
|
const u = await prisma.user
|
||||||
.findUnique({
|
.findUnique({
|
||||||
@@ -36,7 +41,9 @@ export default async function AdminLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<meta name="csrf-token" content={csrfToken} />
|
{csrfToken ? (
|
||||||
|
<meta name="csrf-token" content={csrfToken} />
|
||||||
|
) : null}
|
||||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||||
<div
|
<div
|
||||||
data-admin
|
data-admin
|
||||||
|
|||||||
@@ -6,12 +6,46 @@ import type { IpAddress } from "./types";
|
|||||||
|
|
||||||
const CSRF_BYTES = 32;
|
const CSRF_BYTES = 32;
|
||||||
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
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. */
|
const CSRF_COOKIE_NAMES = [CSRF_COOKIE_HOST, CSRF_COOKIE_FALLBACK] as const;
|
||||||
function csrfCookieName(): string {
|
|
||||||
return process.env.NODE_ENV === "production"
|
/** HTTPS from reverse proxy (Traefik) or configured APP_URL. */
|
||||||
? "__Host-csrf-token"
|
async function isRequestSecure(): Promise<boolean> {
|
||||||
: "csrf-token";
|
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(
|
const ALLOWED_HOSTS: ReadonlySet<string> = new Set(
|
||||||
@@ -64,7 +98,11 @@ export function redirectSafe(
|
|||||||
redirect(safeRedirect(destination, fallback));
|
redirect(safeRedirect(destination, fallback));
|
||||||
}
|
}
|
||||||
|
|
||||||
function csrfCookieOpts(value: string): {
|
function csrfCookieOpts(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
secure: boolean,
|
||||||
|
): {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
httpOnly: boolean;
|
httpOnly: boolean;
|
||||||
@@ -73,36 +111,67 @@ function csrfCookieOpts(value: string): {
|
|||||||
path: string;
|
path: string;
|
||||||
maxAge: number;
|
maxAge: number;
|
||||||
} {
|
} {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
return {
|
return {
|
||||||
name: csrfCookieName(),
|
name,
|
||||||
value,
|
value,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProd,
|
secure,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
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> {
|
export async function setCsrfCookie(): Promise<string> {
|
||||||
const c = await cookies();
|
try {
|
||||||
const name = csrfCookieName();
|
const c = await cookies();
|
||||||
const existing = c.get(name);
|
const existing = readExistingCsrfCookie(c);
|
||||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2)
|
if (existing) return existing;
|
||||||
return existing.value;
|
|
||||||
const value = crypto.randomBytes(CSRF_BYTES).toString("hex");
|
const value = crypto.randomBytes(CSRF_BYTES).toString("hex");
|
||||||
const opts = csrfCookieOpts(value);
|
const secure = await isRequestSecure();
|
||||||
c.set(opts.name, opts.value, opts);
|
const primary = csrfCookieOpts(
|
||||||
return value;
|
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> {
|
export async function validateCsrfToken(token: string): Promise<boolean> {
|
||||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||||
try {
|
try {
|
||||||
const c = await cookies();
|
const c = await cookies();
|
||||||
const stored = c.get(csrfCookieName())?.value;
|
let stored: string | undefined;
|
||||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
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));
|
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in new issue
Block a user