-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add applyDefaultSecurityHeaders convenience export #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| module( | ||
| name = "tummycrypt_tinyland_security", | ||
| version = "0.2.3", | ||
| version = "0.3.0", | ||
| compatibility_level = 1, | ||
| ) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string | null>; | ||
| } | ||
|
|
||
| const STRICT_HEADERS: Readonly<Record<string, string>> = 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<Record<string, string>> = Object.freeze({ | ||
| ...STRICT_HEADERS, | ||
| "Cross-Origin-Resource-Policy": "same-site", | ||
| "X-Frame-Options": "SAMEORIGIN", | ||
| }); | ||
|
|
||
| const PERMISSIVE_HEADERS: Readonly<Record<string, string>> = Object.freeze({ | ||
| "Referrer-Policy": "strict-origin-when-cross-origin", | ||
| "X-Content-Type-Options": "nosniff", | ||
| }); | ||
|
|
||
| const PRESETS: Readonly<Record<SecurityHeaderPreset, Readonly<Record<string, string>>>> = | ||
| 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<string, string> { | ||
| const { preset = "strict", overrides = {} } = options; | ||
| const resolved: Record<string, string> = { ...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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
Comment on lines
+15
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test checks only that
Suggested change
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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"); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
moderateCSP still containsframe-ancestors 'none', neutralising the framing relaxationMODERATE_HEADERSspreadsSTRICT_HEADERSwithout overridingContent-Security-Policy, so it inheritsframe-ancestors 'none'. All modern browsers (Chrome, Firefox, Safari) follow CSPframe-ancestorsoverX-Frame-Options; theSAMEORIGINvalue in this preset is therefore a no-op — framing is still universally blocked, directly contradicting the preset's documented purpose of "allow same-site framing."The CSP must be overridden to replace
frame-ancestors 'none'withframe-ancestors 'self'to match the advertised behaviour.