fix: bootstrap admin CSRF tokens
This commit is contained in:
1 parent
a57c73055f
commit
b3245a18ea
8 files changed
+270
-45
No files matched your search
@@ -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 })
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 })
|
||||||
|
|||||||
+86
-24
@@ -1,31 +1,93 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolve } from "node:path";
|
import { adminFetch } from "./admin-fetch";
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("admin CSRF wiring", () => {
|
const TOKEN = "a".repeat(64);
|
||||||
it("defaults CSRF on for mutating withAdmin handlers", () => {
|
const REFRESHED_TOKEN = "b".repeat(64);
|
||||||
const source = readFileSync(
|
|
||||||
resolve(process.cwd(), "src/lib/api-handler.ts"),
|
function installDocument(initialToken: string | null) {
|
||||||
"utf8",
|
let token = initialToken;
|
||||||
);
|
const meta = {
|
||||||
expect(source).toContain("options.requireCsrf !== false");
|
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", () => {
|
afterEach(() => {
|
||||||
const source = readFileSync(
|
vi.unstubAllGlobals();
|
||||||
resolve(process.cwd(), "src/app/admin/layout.tsx"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(source).toContain("setCsrfCookie");
|
|
||||||
expect(source).toContain('meta name="csrf-token"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides adminFetch helper that sets x-csrf-token", () => {
|
it("bootstraps a missing CSRF token before a mutating request", async () => {
|
||||||
const source = readFileSync(
|
installDocument(null);
|
||||||
resolve(process.cwd(), "src/lib/admin-fetch.ts"),
|
const fetchMock = vi
|
||||||
"utf8",
|
.fn<typeof fetch>()
|
||||||
);
|
.mockResolvedValueOnce(jsonResponse({ ok: true, token: TOKEN }))
|
||||||
expect(source).toContain("x-csrf-token");
|
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||||
expect(source).toContain("getCsrfToken");
|
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<typeof fetch>()
|
||||||
|
.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<typeof fetch>().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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+65
-6
@@ -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 });
|
||||||
}
|
}
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user