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>
145 lines
6.8 KiB
JavaScript
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");
|