Files
scan-receipts/tests/e2e/csrf_tokens.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

298 lines
10 KiB
TypeScript

/**
* 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;
}
});
});