fix: bootstrap admin CSRF tokens
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 41s

This commit is contained in:
Simo committed 2026-08-02 11:46:43 +02:00
1 parent a57c73055f
commit b3245a18ea
8 files changed
+270 -45

No files matched your search

+3 -8
View File
@@ -17,11 +17,11 @@ import {
parseAdminNavConfig, parseAdminNavConfig,
} from "@/lib/admin-nav-config"; } from "@/lib/admin-nav-config";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { setCsrfCookie } from "@/lib/foundation/security"; import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
// Request-time auth: requireStaff, CSRF cookie, and optional 2FA gate cannot be // Request-time auth, existing CSRF cookie, and optional 2FA gate cannot be
// statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant. // statically rendered. Child admin pages inherit this — leaf force-dynamic is redundant.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,12 +31,7 @@ export default async function AdminLayout({
children: ReactNode; children: ReactNode;
}) { }) {
const staff = await requireStaff(); const staff = await requireStaff();
let csrfToken = ""; const csrfToken = await readCsrfCookieToken();
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 db const u = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt }) .select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
setCsrfCookie: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin:
(_options: unknown, handler: (...args: never[]) => unknown) =>
(...args: never[]) =>
handler(...args),
}));
vi.mock("@/lib/foundation/security", () => ({
setCsrfCookie: mocks.setCsrfCookie,
}));
import { GET } from "./route";
describe("admin CSRF bootstrap route", () => {
beforeEach(() => {
mocks.setCsrfCookie.mockReset();
});
it("returns the token written by the route handler without caching", async () => {
const token = "a".repeat(64);
mocks.setCsrfCookie.mockResolvedValue(token);
const response = await GET(
new NextRequest("http://localhost/api/admin/csrf"),
);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ ok: true, token });
});
it("fails closed when the cookie cannot be written", async () => {
mocks.setCsrfCookie.mockResolvedValue("");
const response = await GET(
new NextRequest("http://localhost/api/admin/csrf"),
);
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
error: "Unable to initialize CSRF token",
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { setCsrfCookie } from "@/lib/foundation/security";
export const GET = withAdmin({ requireCsrf: false }, async () => {
const token = await setCsrfCookie();
if (!token) return apiError("Unable to initialize CSRF token", 500);
const response = apiOk({ token });
response.headers.set("Cache-Control", "no-store");
return response;
});
+2 -7
View File
@@ -17,7 +17,7 @@ import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireMod } from "@/lib/admin/guard"; import { requireMod } from "@/lib/admin/guard";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { setCsrfCookie } from "@/lib/foundation/security"; import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -25,12 +25,7 @@ export const dynamic = "force-dynamic";
export default async function ModLayout({ children }: { children: ReactNode }) { export default async function ModLayout({ children }: { children: ReactNode }) {
const staff = await requireMod(); const staff = await requireMod();
let csrfToken = ""; const csrfToken = await readCsrfCookieToken();
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 db const [u] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt }) .select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
File diff suppressed because it is too large. Load diff
+65 -6
View File
@@ -1,4 +1,9 @@
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const CSRF_ENDPOINT = "/api/admin/csrf";
const CSRF_ERROR = "Invalid or missing CSRF token";
const CSRF_TOKEN_LENGTH = 64;
let csrfBootstrapPromise: Promise<string> | null = null;
/** Read the CSRF token injected by the admin layout `<meta name="csrf-token">`. */ /** Read the CSRF token injected by the admin layout `<meta name="csrf-token">`. */
export function getCsrfToken(): string | null { export function getCsrfToken(): string | null {
@@ -10,22 +15,76 @@ export function getCsrfToken(): string | null {
); );
} }
function writeCsrfToken(token: string) {
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "csrf-token");
document.head.appendChild(meta);
}
meta.setAttribute("content", token);
}
async function bootstrapCsrfToken(): Promise<string> {
if (csrfBootstrapPromise) return csrfBootstrapPromise;
csrfBootstrapPromise = fetch(CSRF_ENDPOINT, {
credentials: "same-origin",
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) throw new Error("Unable to initialize CSRF token");
const data = (await response.json()) as { token?: unknown };
if (
typeof data.token !== "string" ||
data.token.length !== CSRF_TOKEN_LENGTH
) {
throw new Error("Invalid CSRF bootstrap response");
}
writeCsrfToken(data.token);
return data.token;
})
.finally(() => {
csrfBootstrapPromise = null;
});
return csrfBootstrapPromise;
}
async function isCsrfRejection(response: Response): Promise<boolean> {
if (response.status !== 403) return false;
try {
const data = (await response.clone().json()) as { error?: unknown };
return data.error === CSRF_ERROR;
} catch {
return false;
}
}
/** /**
* Same-origin fetch for admin APIs. Attaches `x-csrf-token` on mutating methods. * Same-origin fetch for admin APIs. Attaches `x-csrf-token` on mutating methods.
*/ */
export function adminFetch( export async function adminFetch(
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit, init?: RequestInit,
): Promise<Response> { ): Promise<Response> {
const method = (init?.method ?? "GET").toUpperCase(); const method = (init?.method ?? "GET").toUpperCase();
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
if (MUTATING.has(method)) { const mutating = MUTATING.has(method);
const token = getCsrfToken(); if (mutating) {
if (token) headers.set("x-csrf-token", token); const token = getCsrfToken() ?? (await bootstrapCsrfToken());
headers.set("x-csrf-token", token);
} }
return fetch(input, {
const requestInit = {
...init, ...init,
headers, headers,
credentials: init?.credentials ?? "same-origin", credentials: init?.credentials ?? "same-origin",
}); } satisfies RequestInit;
const response = await fetch(input, requestInit);
if (!mutating || !(await isCsrfRejection(response))) return response;
const refreshedToken = await bootstrapCsrfToken();
headers.set("x-csrf-token", refreshedToken);
return fetch(input, { ...requestInit, headers });
} }
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
cookies: vi.fn(),
headers: vi.fn(),
}));
vi.mock("next/headers", () => ({
cookies: mocks.cookies,
headers: mocks.headers,
}));
import { readCsrfCookieToken } from "./security";
describe("readCsrfCookieToken", () => {
beforeEach(() => {
mocks.cookies.mockReset();
});
it("reads an existing valid CSRF cookie without attempting a write", async () => {
const token = "a".repeat(64);
const set = vi.fn();
mocks.cookies.mockResolvedValue({
get: (name: string) =>
name === "__Host-csrf-token" ? { value: token } : undefined,
set,
});
await expect(readCsrfCookieToken()).resolves.toBe(token);
expect(set).not.toHaveBeenCalled();
});
it("returns an empty token when no valid cookie exists", async () => {
mocks.cookies.mockResolvedValue({
get: () => undefined,
set: vi.fn(),
});
await expect(readCsrfCookieToken()).resolves.toBe("");
});
});
+10
View File
@@ -139,6 +139,16 @@ function trySetCsrfCookie(
} }
} }
/** Read-only access for Server Components, which cannot write response cookies. */
export async function readCsrfCookieToken(): Promise<string> {
try {
return readExistingCsrfCookie(await cookies()) ?? "";
} catch {
logger.warn("Failed to read CSRF cookie");
return "";
}
}
/** Sets the CSRF cookie when possible; returns token or empty string (never throws). */ /** 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> {
try { try {