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>
123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
/**
|
|
* Security Headers Suite — Task A (HSTS / HTTPS-only)
|
|
*
|
|
* Verifies next.config.ts: the Strict-Transport-Security header is configured
|
|
* with the full "max-age=63072000; includeSubDomains; preload" directive, is
|
|
* emitted only when the request actually arrived over HTTPS
|
|
* (x-forwarded-proto: https), and the remaining security headers stay
|
|
* unconditional on the catch-all rule. Pure config logic — no database or
|
|
* running server required.
|
|
*/
|
|
|
|
import { describe, test, expect } from "./runner";
|
|
import configModule from "../../next.config";
|
|
|
|
// Under tsx (ESM) the CJS-style default export can arrive wrapped as
|
|
// `{ default: nextConfig }`; unwrap it so `config.headers()` is callable in
|
|
// either interop mode.
|
|
const config = ((configModule as { default?: typeof configModule }).default ??
|
|
configModule) as typeof configModule;
|
|
|
|
interface HeaderItem {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
interface HeaderCondition {
|
|
type: string;
|
|
key: string;
|
|
value?: string;
|
|
}
|
|
|
|
interface HeaderRule {
|
|
source: string;
|
|
has?: HeaderCondition[];
|
|
headers: HeaderItem[];
|
|
}
|
|
|
|
async function catchAllRules(): Promise<HeaderRule[]> {
|
|
// `headers` is optional on the NextConfig type; next.config.ts always defines it.
|
|
const rules = (await config.headers!()) as HeaderRule[];
|
|
return rules.filter((rule) => rule.source === "/(.*)");
|
|
}
|
|
|
|
function findHstsRule(rules: HeaderRule[]): HeaderRule | undefined {
|
|
return rules.find((rule) =>
|
|
rule.headers.some(
|
|
(header) => header.key.toLowerCase() === "strict-transport-security"
|
|
)
|
|
);
|
|
}
|
|
|
|
describe("Security headers — HSTS (HTTPS-only)", () => {
|
|
test("the Strict-Transport-Security header is configured on the catch-all rule", async () => {
|
|
const rules = await catchAllRules();
|
|
expect(findHstsRule(rules)).toBeDefined();
|
|
});
|
|
|
|
test("HSTS carries max-age=63072000, includeSubDomains and preload", async () => {
|
|
const rule = findHstsRule(await catchAllRules());
|
|
expect(rule).toBeDefined();
|
|
const hsts = rule!.headers.find(
|
|
(header) => header.key.toLowerCase() === "strict-transport-security"
|
|
);
|
|
expect(hsts).toBeDefined();
|
|
expect(hsts!.value).toContain("max-age=63072000");
|
|
expect(hsts!.value).toContain("includeSubDomains");
|
|
expect(hsts!.value).toContain("preload");
|
|
});
|
|
|
|
test("HSTS is emitted only when the request arrived over HTTPS (x-forwarded-proto)", async () => {
|
|
const rule = findHstsRule(await catchAllRules());
|
|
expect(rule).toBeDefined();
|
|
expect(rule!.has).toBeDefined();
|
|
const conditioned = rule!.has!.some(
|
|
(condition) =>
|
|
condition.type === "header" &&
|
|
condition.key.toLowerCase() === "x-forwarded-proto" &&
|
|
condition.value === "https"
|
|
);
|
|
expect(conditioned).toBe(true);
|
|
});
|
|
|
|
test("the remaining security headers stay unconditional on the catch-all rule", async () => {
|
|
const rules = await catchAllRules();
|
|
const unconditional = rules.filter((rule) => !rule.has);
|
|
expect(unconditional.length).toBeGreaterThan(0);
|
|
|
|
const allHeaders = unconditional.flatMap((rule) => rule.headers);
|
|
const keys = new Set(allHeaders.map((header) => header.key.toLowerCase()));
|
|
|
|
expect(keys.has("x-frame-options")).toBe(true);
|
|
expect(keys.has("x-content-type-options")).toBe(true);
|
|
expect(keys.has("referrer-policy")).toBe(true);
|
|
expect(keys.has("permissions-policy")).toBe(true);
|
|
expect(keys.has("content-security-policy")).toBe(true);
|
|
|
|
const xfo = allHeaders.find(
|
|
(header) => header.key.toLowerCase() === "x-frame-options"
|
|
);
|
|
const xcto = allHeaders.find(
|
|
(header) => header.key.toLowerCase() === "x-content-type-options"
|
|
);
|
|
expect(xfo).toBeDefined();
|
|
expect(xcto).toBeDefined();
|
|
expect(xfo!.value).toBe("DENY");
|
|
expect(xcto!.value).toBe("nosniff");
|
|
expect(
|
|
allHeaders.some(
|
|
(header) =>
|
|
header.key.toLowerCase() === "referrer-policy" &&
|
|
header.value.includes("strict-origin-when-cross-origin")
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
allHeaders.some(
|
|
(header) =>
|
|
header.key.toLowerCase() === "content-security-policy" &&
|
|
header.value.length > 0
|
|
)
|
|
).toBe(true);
|
|
});
|
|
});
|