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,
} 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 })
+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 { 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 })