fix(server): enforce app-wide browser security headers (#1541) - #1995
Draft
serenakeyitan wants to merge 2 commits into
Draft
fix(server): enforce app-wide browser security headers (#1541)#1995serenakeyitan wants to merge 2 commits into
serenakeyitan wants to merge 2 commits into
Conversation
Every HTTP response (SPA shell, static assets, API JSON, 404s, error bodies) now carries a code-owned security header set via a Fastify onSend hook: enforced least-privilege CSP (script-src without unsafe-inline/unsafe-eval, frame-ancestors 'none'), HSTS (1y, includeSubDomains), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geo/ payment off), and X-Frame-Options: DENY. The header shape is code-owned in packages/server/src/security-headers.ts; only third-party CSP origin allowlists are configurable per environment (FIRST_TREE_CSP_SCRIPT_ORIGINS / _CONNECT_ORIGINS / _IMG_ORIGINS in the shared server config), with defaults matching the production web bundle's real dependencies (GA4, Clarity, Sentry ingest, GitHub/Google avatar hosts). The schema rejects CSP keyword sources so an env var can never smuggle unsafe-inline into the policy. index.html inline bootstraps (GA4/Clarity, theme) moved to external same-origin files (public/analytics-init.js, public/theme-init.js) so script-src 'self' holds without nonce plumbing; csp.test.ts pins the zero-inline-script layout.
CodeQL flagged the script-scan regex (js/bad-tag-filter): `</script>` did not match end-tag variants like `</script >`, so an inline script closed that way could slip past the CSP regression net. Use happy-dom's DOMParser — a real HTML parser — so tag matching follows the same rules browsers apply, and drop the secondary attribute regex too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1541 (audit finding SEC-041).
What
Every HTTP response the server produces now carries an app-wide browser security header set, stamped by a single Fastify
onSendhook registered inbuildApp:Content-Security-PolicyStrict-Transport-Securitymax-age=31536000; includeSubDomainsX-Content-Type-OptionsnosniffReferrer-Policystrict-origin-when-cross-originPermissions-Policycamera=(), microphone=(), geolocation=(), payment=()X-Frame-OptionsDENY(plus CSPframe-ancestors 'none')The
onSendplacement means the layer reaches every reply shape: SPA shell, SPA deep-link fallback, API JSON, API 404s, asset 404s, and error-handler replies.CSP design
'self','none',data:,blob:) are hardcoded inbuildContentSecurityPolicy; the shared config schema validates each configured entry as anhttps/wssorigin (wildcard subdomains allowed) and rejects keyword/scheme smuggling like'unsafe-inline'orjavascript:, so env vars can widen the allowlist but never weaken the policy.script-src 'self' <origins>with nounsafe-inline, nounsafe-eval, no nonces. Made viable by externalizing both index.html inline bootstraps intopublic/theme-init.js(sync, pre-paint dark mode preserved) andpublic/analytics-init.js(deferred GA4 + Clarity, still hostname-gated to production).connect-srcincludes aws(s)://<host>source derived fromserver.publicUrlfor the live-update WebSocket; analytics/Sentry ingest origins come from the allowlist.img-src 'self' data: blob: <avatar/GA hosts>— uploads/previews keep working; arbitrary remote images in chat markdown are now intentionally blocked (the one deliberate behavior change; documented in the QA case).FIRST_TREE_SECURITY_HEADERS_ENABLED=falseremoves the whole layer (logged warning).Config surface
New
securitygroup in shared server config, documented indocs/cli-reference.md:FIRST_TREE_SECURITY_HEADERS_ENABLED(defaulttrue)FIRST_TREE_CSP_SCRIPT_ORIGINS/FIRST_TREE_CSP_CONNECT_ORIGINS/FIRST_TREE_CSP_IMG_ORIGINS— comma/whitespace-separated origin lists that replace the defaults (defaults = current production dependencies: GA4, Clarity, Sentry ingest, GitHub/Google avatar hosts).Tests
packages/server/src/__tests__/security-headers.test.ts— unit tests pin the exact header set and every CSP directive (incl. never-unsafe-inline/eval andpublicUrl→ ws derivation); integration tests viabuildAppprove all five reply shapes carry the full set and that the kill switch removes it.packages/shared/src/config/__tests__/server-config.test.ts— defaults, env list parsing (trim/lowercase/replace semantics), and rejection of non-origin sources.packages/web/src/__tests__/csp.test.ts— regression net: index.html must contain zero inline scripts, referenced bootstraps must exist inpublic/, and every third-party loader origin inanalytics-init.jsmust be in the defaultscript-srcallowlist.packages/web/src/__tests__/analytics.test.ts— updated to assert against the externalizedanalytics-init.js.Verification
pnpm checkPASS,pnpm typecheckPASS (all 10 tasks)CI_DATABASE_URLpath)main)QA
Added
packages/qa/cases/cross-surface/web-security-headers-csp.md— a formal QA pass with a live browser under the enforced policy is recommended before release (devtools-console CSP-violation watch during an authenticated pass; product tests can't see that layer).