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>
298 lines
10 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|