diff --git a/BUILD.bazel b/BUILD.bazel index a090b69..f7e7481 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -57,7 +57,7 @@ npm_package( ":tinyland_security", ], package = "@tummycrypt/tinyland-security", - version = "0.2.3", + version = "0.3.0", visibility = ["//visibility:public"], ) diff --git a/MODULE.bazel b/MODULE.bazel index 1981645..be67073 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "tummycrypt_tinyland_security", - version = "0.2.3", + version = "0.3.0", compatibility_level = 1, ) diff --git a/package.json b/package.json index 2f89a8f..966930a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyland-security", - "version": "0.2.3", + "version": "0.3.0", "description": "IP hashing, encryption, risk scoring, device detection, VPN detection, rate limiting, and timing-safe utilities", "type": "module", "main": "./dist/index.js", diff --git a/src/index.ts b/src/index.ts index 490db36..4ee0758 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,3 +122,13 @@ export { deactivateIpBan, getActiveBans, cleanupExpiredBans, configureIpBanStore } from './ipBans.js'; + + +export { + applyDefaultSecurityHeaders, + getDefaultSecurityHeaders, +} from './securityHeaders.js'; +export type { + SecurityHeaderPreset, + SecurityHeadersOptions, +} from './securityHeaders.js'; diff --git a/src/securityHeaders.ts b/src/securityHeaders.ts new file mode 100644 index 0000000..a95eb77 --- /dev/null +++ b/src/securityHeaders.ts @@ -0,0 +1,101 @@ +/** + * Security headers for HTTP responses. + * + * Provides convenience functions for applying a known-safe set of security + * headers to a Headers object, with three presets reflecting common deployment + * postures: + * + * - `strict`: maximum restriction, suitable for admin apps and authenticated + * surfaces. Frame-ancestors none, COOP/CORP same-origin, locked-down + * Permissions-Policy, base-uri/form-action self. + * - `moderate`: relaxes CORP to same-site and X-Frame-Options to SAMEORIGIN + * for apps that embed within the same site or allow same-site framing. + * - `permissive`: minimal headers (Referrer-Policy + X-Content-Type-Options). + * For public read-only surfaces where CSP would block legitimate behavior. + * + * `applyDefaultSecurityHeaders` is idempotent — it only sets headers that + * are not already present, so consumers can override individual headers + * upstream and still benefit from the rest of the preset. + */ + +export type SecurityHeaderPreset = "strict" | "moderate" | "permissive"; + +export interface SecurityHeadersOptions { + /** Which preset to start from. Defaults to "strict". */ + preset?: SecurityHeaderPreset; + /** + * Per-header overrides applied on top of the preset. + * Pass a string to set a header value. + * Pass `null` to remove a header that the preset would otherwise apply. + */ + overrides?: Record; +} + +const STRICT_HEADERS: Readonly> = Object.freeze({ + "Content-Security-Policy": + "base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'", + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Resource-Policy": "same-origin", + "Permissions-Policy": + "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}); + +const MODERATE_HEADERS: Readonly> = Object.freeze({ + ...STRICT_HEADERS, + "Cross-Origin-Resource-Policy": "same-site", + "X-Frame-Options": "SAMEORIGIN", +}); + +const PERMISSIVE_HEADERS: Readonly> = Object.freeze({ + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", +}); + +const PRESETS: Readonly>>> = + Object.freeze({ + strict: STRICT_HEADERS, + moderate: MODERATE_HEADERS, + permissive: PERMISSIVE_HEADERS, + }); + +/** + * Get the resolved security header map for a preset, with overrides applied. + * + * Returns a fresh object each call — the caller is free to mutate it. + */ +export function getDefaultSecurityHeaders( + options: SecurityHeadersOptions = {}, +): Record { + const { preset = "strict", overrides = {} } = options; + const resolved: Record = { ...PRESETS[preset] }; + for (const [key, value] of Object.entries(overrides)) { + if (value === null) { + delete resolved[key]; + } else { + resolved[key] = value; + } + } + return resolved; +} + +/** + * Apply security headers to a Headers object. + * + * Only sets headers that are not already present, so callers can override + * individual headers upstream (e.g. setting CSP per-route) and the preset + * fills in the rest. + */ +export function applyDefaultSecurityHeaders( + headers: Headers, + options: SecurityHeadersOptions = {}, +): void { + const resolved = getDefaultSecurityHeaders(options); + for (const [key, value] of Object.entries(resolved)) { + if (!headers.has(key)) { + headers.set(key, value); + } + } +} diff --git a/tests/securityHeaders.test.ts b/tests/securityHeaders.test.ts new file mode 100644 index 0000000..7258824 --- /dev/null +++ b/tests/securityHeaders.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + applyDefaultSecurityHeaders, + getDefaultSecurityHeaders, +} from "../src/securityHeaders.js"; + +describe("getDefaultSecurityHeaders", () => { + it("returns the strict preset by default", () => { + const headers = getDefaultSecurityHeaders(); + expect(headers["Content-Security-Policy"]).toContain("frame-ancestors 'none'"); + expect(headers["X-Frame-Options"]).toBe("DENY"); + expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-origin"); + }); + + it("relaxes X-Frame-Options and CORP for moderate", () => { + const headers = getDefaultSecurityHeaders({ preset: "moderate" }); + expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN"); + expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site"); + expect(headers["Content-Security-Policy"]).toBeDefined(); + }); + + it("emits only minimal headers for permissive", () => { + const headers = getDefaultSecurityHeaders({ preset: "permissive" }); + expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(headers["Content-Security-Policy"]).toBeUndefined(); + expect(headers["X-Frame-Options"]).toBeUndefined(); + }); + + it("applies string overrides on top of preset", () => { + const headers = getDefaultSecurityHeaders({ + preset: "strict", + overrides: { "X-Frame-Options": "SAMEORIGIN" }, + }); + expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN"); + expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-origin"); + }); + + it("removes headers when override is null", () => { + const headers = getDefaultSecurityHeaders({ + preset: "strict", + overrides: { "Content-Security-Policy": null }, + }); + expect(headers["Content-Security-Policy"]).toBeUndefined(); + expect(headers["X-Frame-Options"]).toBe("DENY"); + }); + + it("returns a fresh object each call", () => { + const a = getDefaultSecurityHeaders(); + const b = getDefaultSecurityHeaders(); + a["Custom"] = "x"; + expect(b["Custom"]).toBeUndefined(); + }); +}); + +describe("applyDefaultSecurityHeaders", () => { + it("sets all preset headers on an empty Headers object", () => { + const headers = new Headers(); + applyDefaultSecurityHeaders(headers); + expect(headers.get("X-Frame-Options")).toBe("DENY"); + expect(headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + }); + + it("does not overwrite existing headers", () => { + const headers = new Headers(); + headers.set("X-Frame-Options", "SAMEORIGIN"); + applyDefaultSecurityHeaders(headers); + expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN"); + }); + + it("respects preset choice", () => { + const headers = new Headers(); + applyDefaultSecurityHeaders(headers, { preset: "moderate" }); + expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN"); + }); + + it("respects overrides", () => { + const headers = new Headers(); + applyDefaultSecurityHeaders(headers, { + preset: "strict", + overrides: { "X-Custom": "tinyland" }, + }); + expect(headers.get("X-Custom")).toBe("tinyland"); + expect(headers.get("X-Frame-Options")).toBe("DENY"); + }); + + it("removes preset headers when override is null", () => { + const headers = new Headers(); + applyDefaultSecurityHeaders(headers, { + overrides: { "Content-Security-Policy": null }, + }); + expect(headers.has("Content-Security-Policy")).toBe(false); + expect(headers.get("X-Frame-Options")).toBe("DENY"); + }); +});