diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 2d706320..d6b91ab3 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -17,11 +17,11 @@ import { parseAdminNavConfig, } from "@/lib/admin-nav-config"; 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 { 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. export const dynamic = "force-dynamic"; @@ -31,12 +31,7 @@ export default async function AdminLayout({ children: ReactNode; }) { const staff = await requireStaff(); - let csrfToken = ""; - try { - csrfToken = await setCsrfCookie(); - } catch { - csrfToken = ""; - } + const csrfToken = await readCsrfCookieToken(); if (await siteSettings.getBool("force_staff_2fa", false)) { const u = await db .select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt }) diff --git a/src/app/api/admin/csrf/route.test.ts b/src/app/api/admin/csrf/route.test.ts new file mode 100644 index 00000000..c2717c27 --- /dev/null +++ b/src/app/api/admin/csrf/route.test.ts @@ -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", + }); + }); +}); diff --git a/src/app/api/admin/csrf/route.ts b/src/app/api/admin/csrf/route.ts new file mode 100644 index 00000000..0c985add --- /dev/null +++ b/src/app/api/admin/csrf/route.ts @@ -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; +}); diff --git a/src/app/mod/layout.tsx b/src/app/mod/layout.tsx index 6215536e..765bcc19 100644 --- a/src/app/mod/layout.tsx +++ b/src/app/mod/layout.tsx @@ -17,7 +17,7 @@ import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { requireMod } from "@/lib/admin/guard"; 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 { siteSettings } from "@/lib/services/site-settings"; @@ -25,12 +25,7 @@ export const dynamic = "force-dynamic"; export default async function ModLayout({ children }: { children: ReactNode }) { const staff = await requireMod(); - let csrfToken = ""; - try { - csrfToken = await setCsrfCookie(); - } catch { - csrfToken = ""; - } + const csrfToken = await readCsrfCookieToken(); if (await siteSettings.getBool("force_staff_2fa", false)) { const [u] = await db .select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt }) diff --git a/src/lib/admin-fetch.test.ts b/src/lib/admin-fetch.test.ts index a7f5ceaa..72c36cf2 100644 --- a/src/lib/admin-fetch.test.ts +++ b/src/lib/admin-fetch.test.ts @@ -1,31 +1,93 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { adminFetch } from "./admin-fetch"; -describe("admin CSRF wiring", () => { - it("defaults CSRF on for mutating withAdmin handlers", () => { - const source = readFileSync( - resolve(process.cwd(), "src/lib/api-handler.ts"), - "utf8", - ); - expect(source).toContain("options.requireCsrf !== false"); +const TOKEN = "a".repeat(64); +const REFRESHED_TOKEN = "b".repeat(64); + +function installDocument(initialToken: string | null) { + let token = initialToken; + const meta = { + getAttribute: (name: string) => (name === "content" ? token : null), + setAttribute: (name: string, value: string) => { + if (name === "content") token = value; + }, + }; + + vi.stubGlobal("document", { + querySelector: () => (token ? meta : null), + createElement: () => meta, + head: { appendChild: vi.fn() }, + }); +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("adminFetch CSRF handling", () => { + beforeEach(() => { + vi.unstubAllGlobals(); }); - it("issues a csrf meta tag from the admin layout", () => { - const source = readFileSync( - resolve(process.cwd(), "src/app/admin/layout.tsx"), - "utf8", - ); - expect(source).toContain("setCsrfCookie"); - expect(source).toContain('meta name="csrf-token"'); + afterEach(() => { + vi.unstubAllGlobals(); }); - it("provides adminFetch helper that sets x-csrf-token", () => { - const source = readFileSync( - resolve(process.cwd(), "src/lib/admin-fetch.ts"), - "utf8", - ); - expect(source).toContain("x-csrf-token"); - expect(source).toContain("getCsrfToken"); + it("bootstraps a missing CSRF token before a mutating request", async () => { + installDocument(null); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ ok: true, token: TOKEN })) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + const response = await adminFetch("/api/admin/import/furni", { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/admin/csrf"); + const requestHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); + expect(requestHeaders.get("x-csrf-token")).toBe(TOKEN); + }); + + it("refreshes and retries once when the server rejects a stale token", async () => { + installDocument(TOKEN); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse( + { ok: false, error: "Invalid or missing CSRF token" }, + 403, + ), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true, token: REFRESHED_TOKEN })) + .mockResolvedValueOnce(jsonResponse({ ok: true, imported: 1 })); + vi.stubGlobal("fetch", fetchMock); + + const response = await adminFetch("/api/admin/import/furni", { + method: "POST", + }); + + expect(await response.json()).toEqual({ ok: true, imported: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(3); + const retryHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers); + expect(retryHeaders.get("x-csrf-token")).toBe(REFRESHED_TOKEN); + }); + + it("does not bootstrap CSRF for read-only requests", async () => { + installDocument(null); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + vi.stubGlobal("fetch", fetchMock); + + const response = await adminFetch("/api/admin/import/furni"); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/admin/import/furni"); }); }); diff --git a/src/lib/admin-fetch.ts b/src/lib/admin-fetch.ts index 9f6c8e6d..29ad8415 100644 --- a/src/lib/admin-fetch.ts +++ b/src/lib/admin-fetch.ts @@ -1,4 +1,9 @@ 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 | null = null; /** Read the CSRF token injected by the admin layout ``. */ 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 { + 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 { + 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. */ -export function adminFetch( +export async function adminFetch( input: RequestInfo | URL, init?: RequestInit, ): Promise { const method = (init?.method ?? "GET").toUpperCase(); const headers = new Headers(init?.headers); - if (MUTATING.has(method)) { - const token = getCsrfToken(); - if (token) headers.set("x-csrf-token", token); + const mutating = MUTATING.has(method); + if (mutating) { + const token = getCsrfToken() ?? (await bootstrapCsrfToken()); + headers.set("x-csrf-token", token); } - return fetch(input, { + + const requestInit = { ...init, headers, 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 }); } diff --git a/src/lib/foundation/security-csrf.test.ts b/src/lib/foundation/security-csrf.test.ts new file mode 100644 index 00000000..2ff8979b --- /dev/null +++ b/src/lib/foundation/security-csrf.test.ts @@ -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(""); + }); +}); diff --git a/src/lib/foundation/security.ts b/src/lib/foundation/security.ts index 10f7ab59..ab837fc3 100644 --- a/src/lib/foundation/security.ts +++ b/src/lib/foundation/security.ts @@ -139,6 +139,16 @@ function trySetCsrfCookie( } } +/** Read-only access for Server Components, which cannot write response cookies. */ +export async function readCsrfCookieToken(): Promise { + 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). */ export async function setCsrfCookie(): Promise { try {