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>
This commit is contained in:
297
tests/e2e/csrf_tokens.test.ts
Normal file
297
tests/e2e/csrf_tokens.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user