feat: add applyDefaultSecurityHeaders convenience export#4
Conversation
Closes TIN-703.
Adds a small convenience layer for applying standard security headers to
HTTP responses. Three presets:
- strict: max restriction, suitable for admin apps. CSP frame-ancestors none,
COOP/CORP same-origin, locked Permissions-Policy, X-Frame-Options DENY.
- moderate: relaxes CORP to same-site and X-Frame-Options to SAMEORIGIN
for apps that allow same-site framing.
- permissive: minimal headers (Referrer-Policy + X-Content-Type-Options)
for public read-only surfaces where CSP would block legitimate behavior.
API:
applyDefaultSecurityHeaders(headers, options?)
getDefaultSecurityHeaders(options?)
type SecurityHeaderPreset = 'strict' | 'moderate' | 'permissive'
type SecurityHeadersOptions = { preset?, overrides?: Record<string, string|null> }
Idempotent: only sets headers that aren't already present, so callers
can override individual headers upstream and still benefit from the rest
of the preset.
Bumps to 0.3.0 (minor — new public API).
Validation: typecheck ✓, test (119/119) ✓, build ✓, publint ✓
Greptile SummaryThis PR introduces
Confidence Score: 3/5Not safe to merge as-is — the One P1 logic bug in the core feature of the PR: the
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["applyDefaultSecurityHeaders(headers, options)"] --> B["getDefaultSecurityHeaders(options)"]
B --> C{"preset?"}
C -- strict --> D["STRICT_HEADERS\nframe-ancestors 'none'\nCORP: same-origin\nXFO: DENY"]
C -- moderate --> E["MODERATE_HEADERS\n⚠️ frame-ancestors 'none' (inherited!)\nCORP: same-site\nXFO: SAMEORIGIN"]
C -- permissive --> F["PERMISSIVE_HEADERS\nReferrer-Policy only\nX-Content-Type-Options only"]
D --> G["Apply overrides"]
E --> G
F --> G
G --> H["resolved headers map"]
H --> I{"headers.has(key)?"}
I -- No --> J["headers.set(key, value)"]
I -- Yes --> K["skip (idempotent)"]
Reviews (1): Last reviewed commit: "feat: add applyDefaultSecurityHeaders co..." | Re-trigger Greptile |
| const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({ | ||
| ...STRICT_HEADERS, | ||
| "Cross-Origin-Resource-Policy": "same-site", | ||
| "X-Frame-Options": "SAMEORIGIN", | ||
| }); |
There was a problem hiding this comment.
moderate CSP still contains frame-ancestors 'none', neutralising the framing relaxation
MODERATE_HEADERS spreads STRICT_HEADERS without overriding Content-Security-Policy, so it inherits frame-ancestors 'none'. All modern browsers (Chrome, Firefox, Safari) follow CSP frame-ancestors over X-Frame-Options; the SAMEORIGIN value 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' with frame-ancestors 'self' to match the advertised behaviour.
| const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({ | |
| ...STRICT_HEADERS, | |
| "Cross-Origin-Resource-Policy": "same-site", | |
| "X-Frame-Options": "SAMEORIGIN", | |
| }); | |
| const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({ | |
| ...STRICT_HEADERS, | |
| "Content-Security-Policy": | |
| "base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'", | |
| "Cross-Origin-Resource-Policy": "same-site", | |
| "X-Frame-Options": "SAMEORIGIN", | |
| }); |
| 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(); | ||
| }); |
There was a problem hiding this comment.
Moderate-preset CSP test doesn't assert
frame-ancestors value
The test checks only that Content-Security-Policy is defined, which passes even with frame-ancestors 'none' inherited from STRICT. A targeted assertion on the frame-ancestors token would have caught the bug above. Consider asserting that the value contains frame-ancestors 'self' and does not contain 'none'.
| 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("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"]).toContain("frame-ancestors 'self'"); | |
| expect(headers["Content-Security-Policy"]).not.toContain("frame-ancestors 'none'"); | |
| }); |
Summary
Closes TIN-703.
Adds a small convenience layer for applying standard security headers to HTTP responses. Three presets covering common deployment postures.
API
Presets
Per-header
overridesallow strings (set) ornull(remove preset default).Idempotent behavior
applyDefaultSecurityHeadersonly sets headers not already present, so callers can override per-route and the preset fills in the rest.Version bump
0.2.3 → 0.3.0 (minor — new public API, no breaking changes).
Validation
Consumer migration
After merge + tag:
src/lib/server/security-headers.ts(23 lines) and imports from this packagehooks.server.ts(~20 lines)Tracked in TIN-698 (Stage 1 consumer migration).