fix(security): add Content-Security-Policy headers to SvelteKit responses #116
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The application has no
Content-Security-Policyheader. Svelte's default output escaping prevents most XSS today, but CSP is the browser-enforced safety net — it blocks script execution even if an injection slips through (e.g. via a future{@html}block, a compromised CDN asset, or a third-party library).Without CSP, there is no second line of defence.
Approach
Phase 1 — Report-Only (safe, no user impact):
Add
Content-Security-Policy-Report-Onlyfirst. Violations are logged to the browser console (and optionally a report endpoint) but nothing is blocked. This reveals what a real policy would need to allow before you lock anything down.Phase 2 — Enforce (after observing violations for 1–2 weeks):
Rename to
Content-Security-Policyonce the policy is confirmed clean.Additional headers to add at the same time
Acceptance Criteria
Content-Security-Policy-Report-Onlyheader present on all page responsesContent-Security-Policywith no violationsX-Frame-Options,X-Content-Type-Options,Referrer-Policyheaders present👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
sequence()wrapper inhooks.server.ts— the CSP handler should run last in the sequence so it wraps the fully resolved response, including responses from other hooks. The issue's code shows this correctly withawait resolve(event)inside the handler.sequence()in this project'shooks.server.tsalready exist, and what's in it? Adding a new handler needs to slot in without breaking existing auth/locale handlers.Suggestions
svelte.config.js(kit.csp) that generates per-request nonces automatically for inline scripts. Consider using it instead of manual header injection — it integrates with SSR rendering and avoids the'unsafe-inline'concession for scripts:content-security-policy-report-only(Phase 1) is present with the expected directives. This test becomes the guard for Phase 2 enforcement too.sequence()import if it's already imported — don't duplicate it.🔒 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
blob:URLs for its worker,data:URIs for inline content, and in some configurations requireswasm-unsafe-evalfor WebAssembly. The proposed policy doesn't includeworker-src blob:— this will produce violations the moment the PDF viewer is opened. Add it to the policy before Phase 1 ships:'unsafe-inline'for styles. Tailwind 4 generates utility classes at build time — it shouldn't need'unsafe-inline'for styles in production. But SvelteKit's Vite-based dev server injects inline styles for HMR. The dev and production profiles may need different CSP headers. Confirm withContent-Security-Policy-Report-Onlyin staging before enforcing.svelte.config.jsnonce approach is better. SvelteKit's built-inkit.cspconfig generates per-request nonces for inline scripts, which lets you drop'unsafe-inline'fromscript-srcentirely. The manual header approach in the issue works but misses this opportunity.X-Frame-Options: DENY,X-Content-Type-Options: nosniff, andReferrer-Policy: strict-origin-when-cross-originare the right choices.Suggestions
Permissions-Policy: camera=(), microphone=(), geolocation=()while you're adding headers — zero cost, good hygiene.🧪 Sara Holt — QA Engineer & Test Strategist
Test Strategy
CSP headers are response-header tests — Playwright is the right layer, not unit tests.
E2E — Playwright (Phase 1):
Phase 2 test (after enforcement):
Observations
🏗️ Markus Keller — Application Architect
Questions & Observations
svelte.config.jskit.cspvs manual header injection — use the framework. SvelteKit's built-in CSP support (kit.csp.mode: 'auto') generates per-request nonces, injects them into<script>tags at render time, and handles the header — all in one place. The manualhooks.server.tsapproach works but bypasses this integration and requires'unsafe-inline'for scripts. Use the framework mechanism:reportOnly→ enforce flip is a config change, not a code change. Add a follow-up issue now so it doesn't fall off the backlog.X-Frame-Options,X-Content-Type-Options,Referrer-Policy) belong inhooks.server.ts— they're not managed bykit.csp. These are fine as manual header injections.Suggestions
🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist
Questions & Observations
style-srcdoesn't include'unsafe-inline'. A broken layout is a worse UX outcome than a missing security header. The Phase 1 / Report-Only approach is essential — don't skip it.'unsafe-inline'for most utility classes. However, any component that uses Svelte'sstyle:directive or inlinestyle="..."attributes will generate violations. Audit the components before enforcing — the document viewer and PDF controls are likely candidates.Suggestions
🚀 Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
headerdirectives. The question is whether to set CSP in SvelteKit (application layer) or Caddy (infrastructure layer). My preference: application layer wins for CSP because CSP often needs per-route variation (e.g. the PDF viewer needsworker-src blob:, other pages don't). Caddy can handle the static headers (X-Frame-Options,X-Content-Type-Options,Referrer-Policy) since those don't vary by route.reportOnlytoContent-Security-Policy, run the Playwright E2E suite against a staging environment with enforcement active. A CSP violation in production blocks the feature entirely for all users — staging validation is not optional.Permissions-Policyheader (as NullX suggests) can be set in Caddy globally with zero application-layer changes — zero cost addition.Suggestions
X-Frame-Options,X-Content-Type-Options, andReferrer-Policyto the Caddy config indocker-compose.prod.yml's Caddyfile, not inhooks.server.ts. Keep security header responsibility split: Caddy owns static hardening headers, SvelteKit owns CSP (because it needs nonces and route awareness).Audit confirmation + scope refinement (2026-05-07)
Live
curl -Iconfirms the gap is specifically on the SvelteKit frontend (port 5173 / production node adapter), not the backend:So the backend already gets Spring Security's defaults; the user-facing frontend ships zero headers. The fix is exclusively on the SvelteKit side.
Recommended
svelte.config.jsPlus a
securityHeadersstep inhooks.server.tsSuggested AC
reportOnlymode for one week to surface unintended violations before enforcement.connect-srcallowlists exactly the backend host (e.g.,https://api.familienarchiv.example); no wildcards.isomorphic-dompurifybefore persistence (defense in depth)./documentsin Chrome with DevTools → Console; zero CSP violations on a clean load.Forwarded: proto=https(don't set on bare HTTP staging).This is the chained-vulnerability mitigator for F-13 — without CSP, an XSS in TipTap content directly exfiltrates the cookie holding
Basic <base64(email:password)>.Tracked in audit doc as F-01 (Critical). See
docs/audits/2026-05-07-pre-prod-architectural-review.md.