/** * CSRF Token Suite — pure logic, no database required. * * Covers the double-submit token contract in `src/lib/auth/csrf.ts`: issuance * (fresh, 32-byte, base64url), constant-time comparison semantics, the Origin * allow-list, and the client mirror in `src/lib/csrf/client.ts` (which must * stay in sync with the server constants it cannot import). */ import { describe, test, expect } from "./runner"; import { CSRF_COOKIE, CSRF_HEADER, csrfCookieOptions, isAllowedOrigin, issueCsrfToken, requireCsrf, tokensMatch, validateCsrf, } from "../../src/lib/auth/csrf"; import { siteUrl, hostIsSiteFirstParty } from "../../src/lib/seo/site"; import { apiFetch, CSRF_COOKIE as CLIENT_CSRF_COOKIE, CSRF_HEADER as CLIENT_CSRF_HEADER, } from "../../src/lib/csrf/client"; function requestWith(init?: RequestInit): Request { return new Request("http://localhost:3000/api/auth/logout", { method: "POST", ...init, }); } describe("CSRF — token issuance", () => { test("tokens are unique across many issuances", () => { const tokens = new Set(Array.from({ length: 500 }, () => issueCsrfToken())); expect(tokens.size).toBe(500); }); test("tokens are 32 random bytes in base64url — 43 chars, URL-safe alphabet", () => { const token = issueCsrfToken(); expect(token.length).toBe(43); expect(/^[A-Za-z0-9_-]+$/.test(token)).toBe(true); expect(token.includes("+")).toBe(false); expect(token.includes("/")).toBe(false); expect(token.includes("=")).toBe(false); }); test("cookie options are the documented double-submit shape", () => { const options = csrfCookieOptions(); expect(options.httpOnly).toBe(false); // JS must be able to read the token expect(options.sameSite).toBe("lax"); expect(options.path).toBe("/"); expect(options.maxAge).toBe(86400); expect(typeof options.secure).toBe("boolean"); }); test("client wrapper constants mirror the server contract", () => { expect(CLIENT_CSRF_COOKIE).toBe(CSRF_COOKIE); expect(CLIENT_CSRF_HEADER).toBe(CSRF_HEADER); expect(CSRF_COOKIE).toBe("sr_csrf"); expect(CSRF_HEADER).toBe("x-csrf-token"); }); }); describe("CSRF — tokensMatch (constant-time comparison)", () => { test("equal tokens match", () => { const token = issueCsrfToken(); expect(tokensMatch(token, token)).toBe(true); }); test("same-length but different tokens never match", () => { const a = issueCsrfToken(); const b = issueCsrfToken(); expect(a === b).toBe(false); expect(tokensMatch(a, b)).toBe(false); }); test("unequal lengths are rejected without comparing content", () => { expect(tokensMatch("abc", "abcd")).toBe(false); expect(tokensMatch("abcd", "abc")).toBe(false); expect(tokensMatch("", "a")).toBe(false); expect(tokensMatch("a", "")).toBe(false); }); test("undefined on either side never matches", () => { const token = issueCsrfToken(); expect(tokensMatch(undefined, undefined)).toBe(false); expect(tokensMatch(token, undefined)).toBe(false); expect(tokensMatch(undefined, token)).toBe(false); }); test("empty strings compare as equal (length 0, no bytes differ)", () => { expect(tokensMatch("", "")).toBe(true); }); }); describe("CSRF — first-party host validation (OAuth return target)", () => { const siteHost = new URL(siteUrl).host; test("the site host itself is first-party", () => { expect(hostIsSiteFirstParty(siteHost)).toBe(true); }); test("app. and admin. subdomains are first-party (dashboard/admin routing)", () => { expect(hostIsSiteFirstParty(`app.${siteHost}`)).toBe(true); expect(hostIsSiteFirstParty(`admin.${siteHost}`)).toBe(true); expect(hostIsSiteFirstParty(`APP.${siteHost.toUpperCase()}`)).toBe(true); }); test("other hosts are NOT first-party — no open redirect through the cookie", () => { expect(hostIsSiteFirstParty(`evil.${siteHost}`)).toBe(false); expect(hostIsSiteFirstParty("app.scan-receipts.app.evil.example")).toBe(false); expect(hostIsSiteFirstParty("attacker.example")).toBe(false); expect(hostIsSiteFirstParty("scanreceipts.app")).toBe(false); }); }); describe("CSRF — origin allow-list", () => { test("a request without an Origin header is trusted (non-browser client)", () => { expect(isAllowedOrigin(requestWith())).toBe(true); }); test("the configured site origin is allowed", () => { expect(isAllowedOrigin(requestWith({ headers: { origin: siteUrl } }))).toBe(true); }); test("a foreign origin is rejected", () => { expect( isAllowedOrigin(requestWith({ headers: { origin: "https://evil.example" } })) ).toBe(false); }); test("a same-host but different scheme is rejected", () => { const http = siteUrl.replace(/^https:/, "http:"); if (http !== siteUrl) { expect(isAllowedOrigin(requestWith({ headers: { origin: http } }))).toBe(false); } }); test("the first-party app./admin. subdomains are allowed (dashboard/admin routing)", () => { const host = new URL(siteUrl).host; const forHost = (label: string, scheme = "https") => requestWith({ headers: { origin: `${scheme}://${label}.${host}` } }); expect(isAllowedOrigin(forHost("app"))).toBe(true); expect(isAllowedOrigin(forHost("admin"))).toBe(true); }); test("a non-routed subdomain of the site is rejected", () => { const host = new URL(siteUrl).host; expect( isAllowedOrigin(requestWith({ headers: { origin: `https://evil.${host}` } })) ).toBe(false); }); test("a lookalike host that merely ends with the site domain is rejected", () => { const host = new URL(siteUrl).host; expect( isAllowedOrigin(requestWith({ headers: { origin: `https://app.${host}.evil.example` } })) ).toBe(false); }); test("a malformed origin header is rejected, never crashes", () => { expect(isAllowedOrigin(requestWith({ headers: { origin: "not a url" } }))).toBe(false); expect(isAllowedOrigin(requestWith({ headers: { origin: "" } }))).toBe(false); }); }); describe("CSRF — validateCsrf / requireCsrf on plain Requests", () => { test("missing cookie and header fails the full check with a 403 csrf_failed", async () => { const request = requestWith(); expect(validateCsrf(request)).toBe(false); const blocked = requireCsrf(request); expect(blocked).not.toBeNull(); expect(blocked!.status).toBe(403); expect(await blocked!.json()).toEqual({ error: "csrf_failed" }); }); test("a matching cookie + header passes", () => { const token = issueCsrfToken(); const request = requestWith({ headers: { cookie: `${CSRF_COOKIE}=${token}`, [CSRF_HEADER]: token, }, }); expect(validateCsrf(request)).toBe(true); expect(requireCsrf(request)).toBeNull(); }); test("a mismatched header is rejected", () => { const request = requestWith({ headers: { cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`, [CSRF_HEADER]: issueCsrfToken(), }, }); expect(validateCsrf(request)).toBe(false); }); test("a cookie without a header is rejected", () => { const request = requestWith({ headers: { cookie: `${CSRF_COOKIE}=${issueCsrfToken()}` }, }); expect(validateCsrf(request)).toBe(false); }); test("a header without a cookie is rejected", () => { const request = requestWith({ headers: { [CSRF_HEADER]: issueCsrfToken() }, }); expect(validateCsrf(request)).toBe(false); }); test("a valid token from a foreign origin is rejected by the origin check", () => { const token = issueCsrfToken(); const request = requestWith({ headers: { origin: "https://evil.example", cookie: `${CSRF_COOKIE}=${token}`, [CSRF_HEADER]: token, }, }); expect(validateCsrf(request)).toBe(false); }); }); describe("CSRF — apiFetch client wrapper", () => { const originalFetch = globalThis.fetch; test("apiFetch echoes the sr_csrf cookie into the x-csrf-token header", async () => { let capturedInit: RequestInit | undefined; (globalThis as { fetch: typeof fetch }).fetch = (input, init) => { capturedInit = init; return Promise.resolve(new Response("{}", { status: 200 })); }; (globalThis as { document?: unknown }).document = { cookie: `${CSRF_COOKIE}=abc123; other=1`, }; try { await apiFetch("/api/test", { method: "POST", body: "x" }); const headers = new Headers(capturedInit?.headers); expect(headers.get(CSRF_HEADER)).toBe("abc123"); expect(capturedInit?.method).toBe("POST"); expect(capturedInit?.body).toBe("x"); } finally { (globalThis as { fetch: typeof fetch }).fetch = originalFetch; delete (globalThis as { document?: unknown }).document; } }); test("apiFetch preserves existing headers and adds the CSRF header", async () => { let capturedInit: RequestInit | undefined; (globalThis as { fetch: typeof fetch }).fetch = (input, init) => { capturedInit = init; return Promise.resolve(new Response("{}", { status: 200 })); }; (globalThis as { document?: unknown }).document = { cookie: `${CSRF_COOKIE}=tok123`, }; try { await apiFetch("/api/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ a: 1 }), }); const headers = new Headers(capturedInit?.headers); expect(headers.get("Content-Type")).toBe("application/json"); expect(headers.get(CSRF_HEADER)).toBe("tok123"); } finally { (globalThis as { fetch: typeof fetch }).fetch = originalFetch; delete (globalThis as { document?: unknown }).document; } }); test("apiFetch sends no CSRF header when the cookie is absent", async () => { let capturedInit: RequestInit | undefined; (globalThis as { fetch: typeof fetch }).fetch = (input, init) => { capturedInit = init; return Promise.resolve(new Response("{}", { status: 200 })); }; (globalThis as { document?: unknown }).document = { cookie: "other=1" }; try { await apiFetch("/api/test", { method: "POST" }); const headers = new Headers(capturedInit?.headers); expect(headers.get(CSRF_HEADER)).toBe(null); } finally { (globalThis as { fetch: typeof fetch }).fetch = originalFetch; delete (globalThis as { document?: unknown }).document; } }); });