Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import type { NextConfig } from "next";
import { securityHeaders } from "./src/lib/security-headers";

const { version } = JSON.parse(readFileSync("./package.json", "utf8")) as { version: string };

Expand All @@ -17,6 +18,7 @@ const nextConfig: NextConfig = {
partialPrefetching: true,
async headers() {
return [
{ source: "/:path*", headers: securityHeaders },
{
// The browser must always revalidate the worker script so updates reach installed PWAs.
source: "/sw.js",
Expand Down
34 changes: 34 additions & 0 deletions src/lib/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import nextConfig from "../../next.config";
import { securityHeaders } from "./security-headers";

const header = (key: string) => securityHeaders.find((h) => h.key === key)?.value;

describe("security headers (COMP-003)", () => {
it("applies to every path", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const all = rules.find((r) => r.source === "/:path*");
expect(all?.headers).toEqual(securityHeaders);
});

it("forbids framing in both the modern and the legacy way", () => {
expect(header("Content-Security-Policy")).toContain("frame-ancestors 'none'");
expect(header("X-Frame-Options")).toBe("DENY");
});

it("sets the rest of the baseline", () => {
expect(header("X-Content-Type-Options")).toBe("nosniff");
expect(header("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(header("Permissions-Policy")).toContain("camera=()");
expect(header("Strict-Transport-Security")).toMatch(/^max-age=\d+$/);
});

it("never preloads HSTS (that is a decision about the whole domain)", () => {
expect(header("Strict-Transport-Security")).not.toMatch(/preload|includeSubDomains/);
});

it("keeps the service worker revalidation rule", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
expect(rules.find((r) => r.source === "/sw.js")?.headers.some((h) => h.key === "Cache-Control")).toBe(true);
});
});
17 changes: 17 additions & 0 deletions src/lib/security-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Security headers sent on every response (audit COMP-003). Kept in a plain module so a test can pin them.
*
* Deliberately NOT a full Content-Security-Policy yet: `script-src` needs per-request nonces with Next 16 and the app has
* inline scripts, so a strict policy must be tried page by page. This subset has no effect on how pages render.
* - `frame-ancestors 'none'` / `X-Frame-Options: DENY`: no framing, so no clickjacking of admin actions.
* - `form-action 'self'`, `base-uri 'self'`, `object-src 'none'`: close the cheapest injection follow-ups.
* - HSTS without `preload`: preload would be a decision about the whole assertlab.com domain, not about this app.
*/
export const securityHeaders = [
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Strict-Transport-Security", value: "max-age=63072000" },
];
Loading