From 96274ba8569b107125deb45fd79c870a54be02b1 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 09:33:13 +0000 Subject: [PATCH] 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 --- coverage/summary.json | 52 ++--- packages/core-shared/package.json | 3 +- packages/core-shared/src/index.ts | 1 + .../security/build-security-headers.test.ts | 194 ++++++++++++++++++ .../src/security/build-security-headers.ts | 87 ++++++++ packages/core-shared/src/security/index.ts | 6 + .../core-shared/src/security/nonce.test.ts | 19 ++ packages/core-shared/src/security/nonce.ts | 5 + .../src/security/security-types.ts | 9 + 9 files changed, 349 insertions(+), 27 deletions(-) create mode 100644 packages/core-shared/src/security/build-security-headers.test.ts create mode 100644 packages/core-shared/src/security/build-security-headers.ts create mode 100644 packages/core-shared/src/security/index.ts create mode 100644 packages/core-shared/src/security/nonce.test.ts create mode 100644 packages/core-shared/src/security/nonce.ts create mode 100644 packages/core-shared/src/security/security-types.ts diff --git a/coverage/summary.json b/coverage/summary.json index 398dfc6..4960f61 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,29 +1,29 @@ { - "generatedAt": "2026-05-20T09:21:52.321Z", - "commit": "91d7a24", + "generatedAt": "2026-05-20T09:32:45.528Z", + "commit": "650a97b", "repo": { - "statements": 97.38, - "branches": 92.39, - "functions": 97.21, - "lines": 97.38, + "statements": 97.42, + "branches": 92.47, + "functions": 97.25, + "lines": 97.42, "counts": { - "lf": 5948, - "lh": 5792, - "brf": 1196, - "brh": 1105, - "fnf": 359, - "fnh": 349 + "lf": 6040, + "lh": 5884, + "brf": 1209, + "brh": 1118, + "fnf": 364, + "fnh": 354 } }, "byPackage": { "@repo/auth": { - "statements": 93.81, + "statements": 93.83, "branches": 92.5, "functions": 100, - "lines": 93.81, + "lines": 93.83, "counts": { - "lf": 872, - "lh": 818, + "lf": 875, + "lh": 821, "brf": 120, "brh": 111, "fnf": 46, @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.21, - "branches": 96.19, - "functions": 92.98, - "lines": 98.21, + "statements": 98.34, + "branches": 96.33, + "functions": 93.28, + "lines": 98.34, "counts": { - "lf": 1176, - "lh": 1155, - "brf": 341, - "brh": 328, - "fnf": 114, - "fnh": 106 + "lf": 1265, + "lh": 1244, + "brf": 354, + "brh": 341, + "fnf": 119, + "fnh": 111 } }, "@repo/core-ui": { diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 7630c7f..ed78cc3 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -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", diff --git a/packages/core-shared/src/index.ts b/packages/core-shared/src/index.ts index a46e861..355ba66 100644 --- a/packages/core-shared/src/index.ts +++ b/packages/core-shared/src/index.ts @@ -4,3 +4,4 @@ export * from "./audit"; export * from "./di"; export * from "./instrumentation/index"; export * from "./rate-limit/index"; +export * from "./security/index"; diff --git a/packages/core-shared/src/security/build-security-headers.test.ts b/packages/core-shared/src/security/build-security-headers.test.ts new file mode 100644 index 0000000..85a7222 --- /dev/null +++ b/packages/core-shared/src/security/build-security-headers.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core-shared/src/security/build-security-headers.ts b/packages/core-shared/src/security/build-security-headers.ts new file mode 100644 index 0000000..0b9e87a --- /dev/null +++ b/packages/core-shared/src/security/build-security-headers.ts @@ -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 { + 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, + }; +} diff --git a/packages/core-shared/src/security/index.ts b/packages/core-shared/src/security/index.ts new file mode 100644 index 0000000..6e4da09 --- /dev/null +++ b/packages/core-shared/src/security/index.ts @@ -0,0 +1,6 @@ +export type { SecurityHeadersConfig, CspMode } from "./security-types"; +export { generateNonce } from "./nonce"; +export { + buildSecurityHeaders, + InvalidSecurityHeadersConfig, +} from "./build-security-headers"; diff --git a/packages/core-shared/src/security/nonce.test.ts b/packages/core-shared/src/security/nonce.test.ts new file mode 100644 index 0000000..ed836cf --- /dev/null +++ b/packages/core-shared/src/security/nonce.test.ts @@ -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); + }); +}); diff --git a/packages/core-shared/src/security/nonce.ts b/packages/core-shared/src/security/nonce.ts new file mode 100644 index 0000000..dc8e246 --- /dev/null +++ b/packages/core-shared/src/security/nonce.ts @@ -0,0 +1,5 @@ +import { randomBytes } from "node:crypto"; + +export function generateNonce(): string { + return randomBytes(16).toString("base64"); +} diff --git a/packages/core-shared/src/security/security-types.ts b/packages/core-shared/src/security/security-types.ts new file mode 100644 index 0000000..680cf35 --- /dev/null +++ b/packages/core-shared/src/security/security-types.ts @@ -0,0 +1,9 @@ +export type CspMode = "prod" | "dev"; + +export type SecurityHeadersConfig = { + mode: CspMode; + nonce?: string; + allowedConnectOrigins?: string[]; + allowedImgOrigins?: string[]; + allowedFontOrigins?: string[]; +};