fix: add CSP and defense-in-depth security response headers

Document responses carried no CSP, X-Frame-Options, Referrer-Policy, or
Permissions-Policy — leaving clickjacking and zero script-injection defense
in depth (security audit T4).

- svelte.config.js: enable kit.csp hash mode. SvelteKit hashes its own inline
  hydration scripts; the inline theme <script> in app.html isn't part of
  %sveltekit.head%, so its sha256 is pinned by hand (csp-config.js) and added
  to script-src alongside object-src 'none', base-uri 'self', frame-ancestors
  'none'. Styles stay unconstrained (Svelte emits dynamic inline style= attrs).
- hooks.server.ts: applySecurityHeaders() sets X-Frame-Options: DENY,
  Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options:
  nosniff, and a Permissions-Policy locking down unused features, only when the
  response hasn't already set them.
- src/csp-theme-hash.test.ts: drift guard against editing the theme script
  without updating THEME_SCRIPT_HASH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 19:27:27 +02:00
parent a44511983d
commit 5784483a57
9 changed files with 165 additions and 5 deletions

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { THEME_SCRIPT_HASH } from '../csp-config.js';
// The theme-flash-prevention <script> in app.html runs inline, so the CSP
// script-src must allow it by hash. SvelteKit's kit.csp hash mode does NOT
// cover app.html template scripts (only what it injects into %sveltekit.head%),
// so we pin the hash by hand in svelte.config.js. This test recomputes the hash
// from the actual app.html bytes and fails if THEME_SCRIPT_HASH drifted — i.e.
// someone edited the theme script without updating the CSP, which would silently
// CSP-block it (theme flash returns, only a console error to show for it).
describe('CSP theme-script hash', () => {
it('matches the current inline theme script in app.html', () => {
// vitest runs with cwd at the frontend package root.
const html = readFileSync(join(process.cwd(), 'src/app.html'), 'utf-8');
const match = html.match(/<script>(.*?)<\/script>/s);
expect(match, 'app.html must contain an inline <script>').not.toBeNull();
const digest = createHash('sha256')
.update(match![1], 'utf-8')
.digest('base64');
expect(THEME_SCRIPT_HASH).toBe(`'sha256-${digest}'`);
});
});

View File

@@ -8,6 +8,7 @@ import {
type MockInstance
} from 'vitest';
import {
applySecurityHeaders,
handle,
setForwardedFor,
shouldBypassProxyTimeout,
@@ -353,6 +354,36 @@ describe('stripHopByHopHeaders', () => {
});
});
describe('applySecurityHeaders', () => {
it('sets the clickjacking and defense-in-depth headers', () => {
const h = applySecurityHeaders(new Headers());
// Clickjacking: legacy + modern. CSP frame-ancestors is emitted by
// SvelteKit's kit.csp config; X-Frame-Options is the belt-and-braces
// fallback for older UAs.
expect(h.get('x-frame-options')).toBe('DENY');
expect(h.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
expect(h.get('x-content-type-options')).toBe('nosniff');
// Lock down powerful features we never use.
const pp = h.get('permissions-policy') ?? '';
expect(pp).toContain('camera=()');
expect(pp).toContain('microphone=()');
expect(pp).toContain('geolocation=()');
});
it('does not clobber a header the response already set', () => {
// If a downstream route deliberately set a stricter Referrer-Policy,
// the helper must not override it.
const h = new Headers({ 'referrer-policy': 'no-referrer' });
applySecurityHeaders(h);
expect(h.get('referrer-policy')).toBe('no-referrer');
});
it('returns the same Headers instance it was given (mutates in place)', () => {
const h = new Headers();
expect(applySecurityHeaders(h)).toBe(h);
});
});
describe('setForwardedFor', () => {
it('overrides any client-supplied x-forwarded-for with the real address', () => {
const headers = new Headers({ 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' });

View File

@@ -105,6 +105,38 @@ export function shouldBypassProxyTimeout(headers: Headers): boolean {
return accept.toLowerCase().includes('text/event-stream');
}
/**
* Defense-in-depth response headers for HTML/document responses.
*
* The Content-Security-Policy itself is emitted by SvelteKit's `kit.csp`
* config (see svelte.config.js) so that `script-src` hashes cover both the
* inline theme script in app.html AND SvelteKit's own hydration inline
* scripts — a hand-rolled `script-src` here would break hydration on the next
* build. What CSP can't (or shouldn't) express, we add here:
*
* - `X-Frame-Options: DENY` — clickjacking guard for UAs predating CSP
* `frame-ancestors` (which the kit.csp policy also sets).
* - `Referrer-Policy` — don't leak full URLs to cross-origin destinations.
* - `X-Content-Type-Options: nosniff` — no MIME sniffing on documents.
* - `Permissions-Policy` — deny powerful features the app never uses.
*
* Each header is only set when absent, so a route that deliberately picked a
* stricter value keeps it. Mutates and returns the passed `Headers`. Exported
* for unit-test coverage.
*/
export function applySecurityHeaders(headers: Headers): Headers {
const defaults: Record<string, string> = {
'x-frame-options': 'DENY',
'referrer-policy': 'strict-origin-when-cross-origin',
'x-content-type-options': 'nosniff',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()'
};
for (const [name, value] of Object.entries(defaults)) {
if (!headers.has(name)) headers.set(name, value);
}
return headers;
}
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/')) {
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
@@ -199,5 +231,7 @@ export const handle: Handle = async ({ event, resolve }) => {
headers: stripHopByHopHeaders(upstream.headers)
});
}
return resolve(event);
const response = await resolve(event);
applySecurityHeaders(response.headers);
return response;
};