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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.4"
|
version = "0.128.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.4"
|
version = "0.128.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
10
frontend/csp-config.js
Normal file
10
frontend/csp-config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Shared CSP constants, kept dependency-free so both svelte.config.js (loaded by
|
||||||
|
// Node when Vite starts) and the vitest drift-guard test (jsdom env) can import
|
||||||
|
// it without pulling in adapter-node / esbuild.
|
||||||
|
|
||||||
|
// sha256 of the inline theme <script> in src/app.html, base64-encoded, wrapped
|
||||||
|
// for the CSP `script-src` allowlist. SvelteKit's kit.csp hash mode does not
|
||||||
|
// cover app.html template scripts, so this is pinned by hand. src/csp-theme-hash.test.ts
|
||||||
|
// recomputes it from app.html and fails if it drifts.
|
||||||
|
export const THEME_SCRIPT_HASH =
|
||||||
|
"'sha256-qj6Oim9siqow/Su+v47sZHJeJci+M/RQwGXn53eSULQ='";
|
||||||
37
frontend/e2e/security-headers.spec.ts
Normal file
37
frontend/e2e/security-headers.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
|
// Defense-in-depth response headers on document navigations (T4 in the security
|
||||||
|
// audit). The CSP is emitted by SvelteKit's kit.csp config; the clickjacking /
|
||||||
|
// referrer / permissions headers by hooks.server.ts. We assert on the raw
|
||||||
|
// response of a document navigation, and separately confirm the inline theme
|
||||||
|
// script still executes under the CSP (its sha256 is allowlisted) by checking
|
||||||
|
// the data-theme attribute it sets — a CSP block would leave it unset.
|
||||||
|
|
||||||
|
test('document responses carry the security headers', async ({ page }) => {
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response, 'navigation returned a response').not.toBeNull();
|
||||||
|
const headers = response!.headers();
|
||||||
|
|
||||||
|
// Clickjacking: both the legacy header and the CSP directive.
|
||||||
|
expect(headers['x-frame-options']).toBe('DENY');
|
||||||
|
expect(headers['content-security-policy']).toContain("frame-ancestors 'none'");
|
||||||
|
// Script-injection surface reduction.
|
||||||
|
expect(headers['content-security-policy']).toContain("object-src 'none'");
|
||||||
|
expect(headers['content-security-policy']).toContain('script-src');
|
||||||
|
// The non-CSP hardening headers.
|
||||||
|
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
|
||||||
|
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(headers['permissions-policy']).toContain('geolocation=()');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the inline theme script executes under the CSP (hash is allowlisted)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// If the theme script were CSP-blocked, data-theme would never be set.
|
||||||
|
// Its presence proves the allowlisted sha256 matches the served script.
|
||||||
|
await page.goto('/');
|
||||||
|
const theme = await page.evaluate(() =>
|
||||||
|
document.documentElement.getAttribute('data-theme')
|
||||||
|
);
|
||||||
|
expect(theme === 'light' || theme === 'dark').toBe(true);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.4",
|
"version": "0.128.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
25
frontend/src/csp-theme-hash.test.ts
Normal file
25
frontend/src/csp-theme-hash.test.ts
Normal 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}'`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type MockInstance
|
type MockInstance
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
applySecurityHeaders,
|
||||||
handle,
|
handle,
|
||||||
setForwardedFor,
|
setForwardedFor,
|
||||||
shouldBypassProxyTimeout,
|
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', () => {
|
describe('setForwardedFor', () => {
|
||||||
it('overrides any client-supplied x-forwarded-for with the real address', () => {
|
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' });
|
const headers = new Headers({ 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' });
|
||||||
|
|||||||
@@ -105,6 +105,38 @@ export function shouldBypassProxyTimeout(headers: Headers): boolean {
|
|||||||
return accept.toLowerCase().includes('text/event-stream');
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname.startsWith('/api/')) {
|
if (event.url.pathname.startsWith('/api/')) {
|
||||||
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
|
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)
|
headers: stripHopByHopHeaders(upstream.headers)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return resolve(event);
|
const response = await resolve(event);
|
||||||
|
applySecurityHeaders(response.headers);
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { THEME_SCRIPT_HASH } from './csp-config.js';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({ out: 'build' })
|
adapter: adapter({ out: 'build' }),
|
||||||
|
// Content-Security-Policy. `mode: 'hash'` makes SvelteKit hash the
|
||||||
|
// inline scripts IT injects (the hydration bootstrap) and append those
|
||||||
|
// hashes to `script-src`. It does NOT hash the theme <script> in
|
||||||
|
// app.html — that template script isn't part of `%sveltekit.head%`, so
|
||||||
|
// we pin its sha256 here by hand (THEME_SCRIPT_HASH). If that script is
|
||||||
|
// edited, the hash drifts and the theme-flash guard would be silently
|
||||||
|
// CSP-blocked; `svelte.config.test.js` recomputes the hash from
|
||||||
|
// app.html and fails if it no longer matches this constant.
|
||||||
|
// Styles are left unconstrained (Svelte emits dynamic inline `style=`
|
||||||
|
// attributes); this policy is clickjacking + script-injection defense
|
||||||
|
// in depth, not a full lockdown. The non-CSP defense-in-depth headers
|
||||||
|
// (X-Frame-Options, Referrer-Policy, Permissions-Policy) live in
|
||||||
|
// hooks.server.ts.
|
||||||
|
csp: {
|
||||||
|
mode: 'hash',
|
||||||
|
directives: {
|
||||||
|
'script-src': ['self', THEME_SCRIPT_HASH],
|
||||||
|
'object-src': ['none'],
|
||||||
|
'base-uri': ['self'],
|
||||||
|
'frame-ancestors': ['none']
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user