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:
@@ -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",
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./audit";
|
||||
export * from "./di";
|
||||
export * from "./instrumentation/index";
|
||||
export * from "./rate-limit/index";
|
||||
export * from "./security/index";
|
||||
|
||||
194
packages/core-shared/src/security/build-security-headers.test.ts
Normal file
194
packages/core-shared/src/security/build-security-headers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/core-shared/src/security/build-security-headers.ts
Normal file
87
packages/core-shared/src/security/build-security-headers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
packages/core-shared/src/security/index.ts
Normal file
6
packages/core-shared/src/security/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { SecurityHeadersConfig, CspMode } from "./security-types";
|
||||
export { generateNonce } from "./nonce";
|
||||
export {
|
||||
buildSecurityHeaders,
|
||||
InvalidSecurityHeadersConfig,
|
||||
} from "./build-security-headers";
|
||||
19
packages/core-shared/src/security/nonce.test.ts
Normal file
19
packages/core-shared/src/security/nonce.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
5
packages/core-shared/src/security/nonce.ts
Normal file
5
packages/core-shared/src/security/nonce.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export function generateNonce(): string {
|
||||
return randomBytes(16).toString("base64");
|
||||
}
|
||||
9
packages/core-shared/src/security/security-types.ts
Normal file
9
packages/core-shared/src/security/security-types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type CspMode = "prod" | "dev";
|
||||
|
||||
export type SecurityHeadersConfig = {
|
||||
mode: CspMode;
|
||||
nonce?: string;
|
||||
allowedConnectOrigins?: string[];
|
||||
allowedImgOrigins?: string[];
|
||||
allowedFontOrigins?: string[];
|
||||
};
|
||||
Reference in New Issue
Block a user