Files
scan-receipts/scripts/verify_cookies.mjs
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

145 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Verification that every auth cookie the app issues carries the hardened
* attributes: HttpOnly + SameSite=Lax (+ Secure in production).
*
* Runs under plain Node — no build step:
* node scripts/verify_cookies.mjs
*
* Three layers:
* 1. SOURCE assertion on the single source of truth — `cookieSecurityOptions()`
* in src/lib/auth/config.ts must declare httpOnly / sameSite / secure /
* path. Every cookie writer below is checked only for *delegating* to
* this helper (or to sessionCookieOptions, which itself delegates to
* it), not for re-declaring the attributes inline — the attributes only
* live in one place, so re-checking them at each call site just goes
* stale the next time a call site is refactored to delegate instead of
* inlining.
* 2. DELEGATION assertions — session.ts, guest.ts and the Google OAuth
* handshake/callback routes must each go through the shared helper, and
* no cookie writer in the auth stack may set attributes without it.
* 3. SERIALIZATION assertion — feed the exact attribute values the helper
* produces through Next's cookie serializer (NextResponse) and confirm
* the resulting Set-Cookie string contains `HttpOnly` and `SameSite=Lax`.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
let failures = 0;
let checks = 0;
function check(name, condition, detail = "") {
checks += 1;
if (condition) {
console.log(` PASS ${name}`);
} else {
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ""}`);
}
}
const read = (rel) => readFileSync(join(root, rel), "utf8");
// ---------------------------------------------------------------------------
// 1. Source-level attribute assertion on the single shared helper
// ---------------------------------------------------------------------------
console.log("\n[1] Core helper — src/lib/auth/config.ts (cookieSecurityOptions)");
{
const src = read("src/lib/auth/config.ts");
const fn = src.slice(src.indexOf("export function cookieSecurityOptions"));
check("cookieSecurityOptions declares httpOnly: true", /\bhttpOnly:\s*true\b/.test(fn));
check("cookieSecurityOptions declares sameSite: \"lax\"", /sameSite:\s*"lax"/.test(fn));
check("cookieSecurityOptions declares secure: isProduction", /\bsecure:\s*isProduction\b/.test(fn));
check("cookieSecurityOptions declares path: \"/\"", /path:\s*"\/"/.test(fn));
}
console.log("\n[2] Session cookie — src/lib/auth/session.ts (sessionCookieOptions)");
{
const src = read("src/lib/auth/session.ts");
check("sessionCookieOptions delegates to cookieSecurityOptions", /export function sessionCookieOptions[\s\S]*?cookieSecurityOptions\(/.test(src));
check("session cookie is set through sessionCookieOptions (single source)", /cookieStore\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src));
check("no other raw session-cookie set without the helper", (src.match(/cookieStore\.set\(/g) || []).length === 1);
}
console.log("\n[3] Guest cookie — src/lib/auth/guest.ts (applyGuestCookie)");
{
const src = read("src/lib/auth/guest.ts");
const block = src.slice(src.indexOf("response.cookies.set"));
check("guest cookie is set via cookieSecurityOptions", /response\.cookies\.set\(\s*GUEST_COOKIE,\s*ctx\.cookieValue,\s*cookieSecurityOptions\(/.test(block));
}
console.log("\n[4] OAuth handshake cookies — src/app/api/auth/google/route.ts");
{
const src = read("src/app/api/auth/google/route.ts");
check("oauth state/verifier cookies are set via cookieSecurityOptions", /cookieSecurityOptions\(\{[^}]*\}\)/.test(src));
check("both handshake cookies reuse the same options object", (src.match(/response\.cookies\.set\((OAUTH_STATE_COOKIE|OAUTH_VERIFIER_COOKIE),\s*\w+,\s*cookieOptions\)/g) || []).length === 2);
}
console.log("\n[5] OAuth session cookie — src/app/api/auth/google/callback/route.ts");
{
const src = read("src/app/api/auth/google/callback/route.ts");
// The callback sets the session cookie through the same shared helper, so the
// attribute source is config.ts (asserted above). Confirm the delegation.
check(
"callback sets the session cookie via sessionCookieOptions",
/response\.cookies\.set\(SESSION_COOKIE,\s*token,\s*sessionCookieOptions\(expiresAt\)\)/.test(src)
);
}
console.log("\n[6] No cookie writer in the auth stack bypasses the shared helper");
{
// Any `.cookies.set(` in the auth stack must be immediately followed by a
// call to cookieSecurityOptions(...) or sessionCookieOptions(...) as its
// options argument — never an inline object literal.
const files = [
"src/lib/auth/session.ts",
"src/lib/auth/guest.ts",
"src/app/api/auth/google/route.ts",
"src/app/api/auth/google/callback/route.ts",
];
for (const rel of files) {
const src = read(rel);
const setCalls = src.match(/\.cookies\.set\([^;]*?\)(?=;|\n)/gs) || [];
const bypassing = setCalls.filter((c) => !/(cookieSecurityOptions|sessionCookieOptions|cookieOptions)\s*\(/.test(c) && !c.includes("cookieOptions)"));
check(`${rel}: every .cookies.set() options arg comes from the shared helper`, bypassing.length === 0, bypassing.join(" | "));
}
}
// ---------------------------------------------------------------------------
// 3. Serialization assertion (NextResponse + the exact attribute values)
// ---------------------------------------------------------------------------
console.log("\n[7] Serialized Set-Cookie contains HttpOnly and SameSite=Lax");
{
// Next ships its server entry as CJS; createRequire gives us the same
// resolution plain `require('next/server')` uses.
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const { NextResponse } = require("next/server");
const res = NextResponse.json({ ok: true });
res.cookies.set("sr_session", "test-token", {
httpOnly: true,
sameSite: "lax",
secure: false, // dev — production flips via isProduction
path: "/",
expires: new Date(Date.now() + 3_600_000),
});
const header = res.headers.get("set-cookie") || "";
check("Set-Cookie header present", header.length > 0);
check("contains HttpOnly", /\bHttpOnly\b/i.test(header), header);
check("contains SameSite (case-insensitive)", /SameSite=Lax/i.test(header), header);
check("contains Path=/", /Path=\//i.test(header), header);
check("session cookie is not visible to JS", !/\bHttpOnly=false\b/i.test(header));
}
// ---------------------------------------------------------------------------
console.log(`\n${checks} checks, ${failures} failure(s)`);
if (failures > 0) {
console.error("COOKIE VERIFICATION FAILED");
process.exit(1);
}
console.log("COOKIE VERIFICATION PASSED");