feat(core-shared): add security headers module with CSP builder and nonce util

Adds framework-agnostic security headers module to core-shared/security:
- SecurityHeadersConfig + CspMode types
- generateNonce() using crypto.randomBytes(16)
- buildSecurityHeaders() emitting all six headers (HSTS, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP) with
  prod (strict-dynamic + nonce threading) and dev (unsafe-inline/eval +
  ws/localhost) CSP modes; URL validation throwing InvalidSecurityHeadersConfig
  on malformed allowedConnect/Img/FontOrigins
- Full unit test suite (24 tests, 100% coverage on runtime files)
- Exported from core-shared barrel and ./security subpath

Blocks story 07 (framework adapters) and stories 08-09 (app wiring).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 09:33:13 +00:00
parent 650a97b1bb
commit 96274ba856
9 changed files with 349 additions and 27 deletions

View File

@@ -21,7 +21,8 @@
"./instrumentation/otel": "./src/instrumentation/otel/index.ts",
"./instrumentation/otel/init-server-node": "./src/instrumentation/otel/init-server-node.ts",
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts"
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
"./security": "./src/security/index.ts"
},
"scripts": {
"build": "tsc --noEmit",

View File

@@ -4,3 +4,4 @@ export * from "./audit";
export * from "./di";
export * from "./instrumentation/index";
export * from "./rate-limit/index";
export * from "./security/index";

View File

@@ -0,0 +1,194 @@
import { describe, it, expect } from "vitest";
import {
buildSecurityHeaders,
InvalidSecurityHeadersConfig,
} from "@/security/build-security-headers";
const ALL_SIX_HEADERS = [
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
"Content-Security-Policy",
] as const;
describe("buildSecurityHeaders", () => {
describe("prod mode — header set", () => {
it("emits all six headers", () => {
const headers = buildSecurityHeaders({ mode: "prod" });
for (const name of ALL_SIX_HEADERS) {
expect(headers).toHaveProperty(name);
}
});
it("sets HSTS with preload", () => {
const headers = buildSecurityHeaders({ mode: "prod" });
expect(headers["Strict-Transport-Security"]).toContain(
"max-age=31536000",
);
expect(headers["Strict-Transport-Security"]).toContain("preload");
});
it("sets X-Frame-Options to DENY", () => {
expect(buildSecurityHeaders({ mode: "prod" })["X-Frame-Options"]).toBe(
"DENY",
);
});
it("sets X-Content-Type-Options to nosniff", () => {
expect(
buildSecurityHeaders({ mode: "prod" })["X-Content-Type-Options"],
).toBe("nosniff");
});
});
describe("prod mode — CSP", () => {
it("uses strict-dynamic in script-src", () => {
const csp = buildSecurityHeaders({ mode: "prod" })[
"Content-Security-Policy"
];
expect(csp).toContain("script-src 'strict-dynamic'");
});
it("threads nonce into script-src when provided", () => {
const nonce = "abc123==";
const csp = buildSecurityHeaders({ mode: "prod", nonce })[
"Content-Security-Policy"
];
expect(csp).toContain(`'strict-dynamic' 'nonce-${nonce}'`);
});
it("omits nonce clause when not provided", () => {
const csp = buildSecurityHeaders({ mode: "prod" })[
"Content-Security-Policy"
];
expect(csp).not.toContain("'nonce-");
});
it("applies allowedConnectOrigins to connect-src", () => {
const csp = buildSecurityHeaders({
mode: "prod",
allowedConnectOrigins: ["https://api.example.com"],
})["Content-Security-Policy"];
expect(csp).toContain("connect-src 'self' https://api.example.com");
});
it("applies allowedImgOrigins to img-src", () => {
const csp = buildSecurityHeaders({
mode: "prod",
allowedImgOrigins: ["https://images.example.com"],
})["Content-Security-Policy"];
expect(csp).toContain("img-src 'self' data: https://images.example.com");
});
it("applies allowedFontOrigins to font-src", () => {
const csp = buildSecurityHeaders({
mode: "prod",
allowedFontOrigins: ["https://fonts.googleapis.com"],
})["Content-Security-Policy"];
expect(csp).toContain("font-src 'self' https://fonts.googleapis.com");
});
});
describe("dev mode — header set", () => {
it("emits all six headers", () => {
const headers = buildSecurityHeaders({ mode: "dev" });
for (const name of ALL_SIX_HEADERS) {
expect(headers).toHaveProperty(name);
}
});
});
describe("dev mode — CSP", () => {
it("uses unsafe-inline and unsafe-eval in script-src", () => {
const csp = buildSecurityHeaders({ mode: "dev" })[
"Content-Security-Policy"
];
expect(csp).toContain("'unsafe-inline'");
expect(csp).toContain("'unsafe-eval'");
});
it("includes ws: localhost:* 127.0.0.1:* in connect-src", () => {
const csp = buildSecurityHeaders({ mode: "dev" })[
"Content-Security-Policy"
];
expect(csp).toContain("ws:");
expect(csp).toContain("localhost:*");
expect(csp).toContain("127.0.0.1:*");
});
it("applies allowedConnectOrigins to connect-src in dev mode", () => {
const csp = buildSecurityHeaders({
mode: "dev",
allowedConnectOrigins: ["https://staging.example.com"],
})["Content-Security-Policy"];
expect(csp).toContain("https://staging.example.com");
});
it("applies allowedImgOrigins to img-src in dev mode", () => {
const csp = buildSecurityHeaders({
mode: "dev",
allowedImgOrigins: ["https://images.example.com"],
})["Content-Security-Policy"];
expect(csp).toContain("img-src 'self' data: https://images.example.com");
});
it("applies allowedFontOrigins to font-src in dev mode", () => {
const csp = buildSecurityHeaders({
mode: "dev",
allowedFontOrigins: ["https://fonts.googleapis.com"],
})["Content-Security-Policy"];
expect(csp).toContain("font-src 'self' https://fonts.googleapis.com");
});
});
describe("URL validation", () => {
it("throws InvalidSecurityHeadersConfig for malformed allowedConnectOrigins", () => {
expect(() =>
buildSecurityHeaders({
mode: "prod",
allowedConnectOrigins: ["not-a-url"],
}),
).toThrow(InvalidSecurityHeadersConfig);
});
it("throws InvalidSecurityHeadersConfig for malformed allowedImgOrigins", () => {
expect(() =>
buildSecurityHeaders({
mode: "prod",
allowedImgOrigins: ["not-a-url"],
}),
).toThrow(InvalidSecurityHeadersConfig);
});
it("throws InvalidSecurityHeadersConfig for malformed allowedFontOrigins", () => {
expect(() =>
buildSecurityHeaders({
mode: "prod",
allowedFontOrigins: ["not-a-url"],
}),
).toThrow(InvalidSecurityHeadersConfig);
});
it("error message names the invalid origin", () => {
expect(() =>
buildSecurityHeaders({
mode: "prod",
allowedConnectOrigins: ["://bad"],
}),
).toThrow(/allowedConnectOrigins/);
});
it("accepts valid https origins without throwing", () => {
expect(() =>
buildSecurityHeaders({
mode: "prod",
allowedConnectOrigins: ["https://api.example.com"],
allowedImgOrigins: ["https://cdn.example.com"],
allowedFontOrigins: ["https://fonts.googleapis.com"],
}),
).not.toThrow();
});
});
});

View File

@@ -0,0 +1,87 @@
import type { SecurityHeadersConfig } from "./security-types";
export class InvalidSecurityHeadersConfig extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidSecurityHeadersConfig";
Object.setPrototypeOf(this, InvalidSecurityHeadersConfig.prototype);
}
}
function validateOrigins(origins: string[], field: string): void {
for (const origin of origins) {
try {
new URL(origin);
} catch {
throw new InvalidSecurityHeadersConfig(
`Invalid URL in ${field}: "${origin}"`,
);
}
}
}
export function buildSecurityHeaders(
opts: SecurityHeadersConfig,
): Record<string, string> {
const {
mode,
nonce,
allowedConnectOrigins = [],
allowedImgOrigins = [],
allowedFontOrigins = [],
} = opts;
validateOrigins(allowedConnectOrigins, "allowedConnectOrigins");
validateOrigins(allowedImgOrigins, "allowedImgOrigins");
validateOrigins(allowedFontOrigins, "allowedFontOrigins");
const imgSrc = ["'self'", "data:", ...allowedImgOrigins].join(" ");
const fontSrc = ["'self'", ...allowedFontOrigins].join(" ");
let csp: string;
if (mode === "prod") {
const nonceClause = nonce ? ` 'nonce-${nonce}'` : "";
const connectSrc = ["'self'", ...allowedConnectOrigins].join(" ");
csp = [
"default-src 'self'",
`script-src 'strict-dynamic'${nonceClause}`,
"style-src 'self' 'unsafe-inline'",
`img-src ${imgSrc}`,
`font-src ${fontSrc}`,
`connect-src ${connectSrc}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ");
} else {
const connectSrc = [
"'self'",
"ws:",
"localhost:*",
"127.0.0.1:*",
...allowedConnectOrigins,
].join(" ");
csp = [
"default-src 'self'",
"script-src 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
`img-src ${imgSrc}`,
`font-src ${fontSrc}`,
`connect-src ${connectSrc}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ");
}
return {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Content-Security-Policy": csp,
};
}

View File

@@ -0,0 +1,6 @@
export type { SecurityHeadersConfig, CspMode } from "./security-types";
export { generateNonce } from "./nonce";
export {
buildSecurityHeaders,
InvalidSecurityHeadersConfig,
} from "./build-security-headers";

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { generateNonce } from "@/security/nonce";
describe("generateNonce", () => {
it("returns a string", () => {
expect(typeof generateNonce()).toBe("string");
});
it("returns base64-encoded 16-byte value", () => {
const nonce = generateNonce();
expect(Buffer.from(nonce, "base64").length).toBe(16);
});
it("returns different values on successive calls", () => {
const n1 = generateNonce();
const n2 = generateNonce();
expect(n1).not.toBe(n2);
});
});

View File

@@ -0,0 +1,5 @@
import { randomBytes } from "node:crypto";
export function generateNonce(): string {
return randomBytes(16).toString("base64");
}

View File

@@ -0,0 +1,9 @@
export type CspMode = "prod" | "dev";
export type SecurityHeadersConfig = {
mode: CspMode;
nonce?: string;
allowedConnectOrigins?: string[];
allowedImgOrigins?: string[];
allowedFontOrigins?: string[];
};