Skip to content

Commit 06de673

Browse files
dani-polaniclaude
andcommitted
feat(security): add Content-Security-Policy in report-only mode
Configure kit.csp (mode auto) so SvelteKit nonces its own scripts and script-src can stay free of 'unsafe-inline'. style-src keeps 'unsafe-inline' for the preview's dynamic inline style attributes. Allowlist covers Google Fonts, GA, Tally, and the example-previews CDN. Move the GA gtag bootstrap out of app.html into bundled JS (deferThirdPartyScripts) so the page ships no inline script. Add a /api/csp-report endpoint that logs violations while running report-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b71b570 commit 06de673

6 files changed

Lines changed: 86 additions & 18 deletions

File tree

bitext/server.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ const port = Number(process.env.PORT || 3000);
1010
const shutdownTimeout = Number(process.env.SHUTDOWN_TIMEOUT || 30) * 1000;
1111

1212
// HSTS is safe because the site is HTTPS-only (Railway terminates TLS, redirects HTTP).
13-
// No CSP yet: the app loads Google Fonts, GA, Tally, and a DigitalOcean CDN, so a correct
14-
// policy needs its own change.
13+
// Content-Security-Policy is set per-page by SvelteKit (kit.csp in svelte.config.js), which can
14+
// add the required nonce/hash to its own scripts. These headers cover everything else (including
15+
// prerendered pages and static assets, which the SvelteKit `handle` hook does not reach).
1516
const SECURITY_HEADERS = {
1617
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
1718
'X-Content-Type-Options': 'nosniff',

bitext/src/app.html

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,11 @@
66
<meta name="theme-color" content="#1e293b" />
77
<meta name="format-detection" content="telephone=no" />
88
<!--
9-
Google Analytics (gtag.js): only the tiny shim runs at load — gtag() calls queue in
10-
dataLayer. The gtag.js library AND the Tally widget are lazy-loaded once the page is
11-
interactive (see +layout.svelte) to keep third-party JS off the critical path.
9+
No inline scripts here on purpose: the GA gtag shim is bootstrapped from bundled JS
10+
(deferThirdPartyScripts in src/lib/analytics/defer-third-party.ts) so the CSP can keep
11+
script-src free of 'unsafe-inline'. gtag.js and the Tally widget are lazy-loaded once
12+
the page is interactive to keep third-party JS off the critical path.
1213
-->
13-
<script>
14-
window.dataLayer = window.dataLayer || [];
15-
function gtag() {
16-
dataLayer.push(arguments);
17-
}
18-
gtag('js', new Date());
19-
gtag('config', 'G-6Z5775NY39');
20-
</script>
2114
%sveltekit.head%
2215
</head>
2316
<body class="light" data-sveltekit-preload-data="hover">

bitext/src/lib/analytics/defer-third-party.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
11
import { GA_MEASUREMENT_ID } from '$lib/brand.js';
22

3+
let bootstrapped = false;
4+
5+
/**
6+
* Define the GA `gtag` shim and queue the initial config. Runs immediately and makes no network
7+
* request: gtag() calls accumulate in `dataLayer` and flush once gtag.js loads. Kept out of
8+
* app.html on purpose, so the page ships no inline script and the CSP can omit
9+
* script-src 'unsafe-inline'.
10+
*/
11+
function bootstrapGtag() {
12+
if (bootstrapped) return;
13+
bootstrapped = true;
14+
const w = window as typeof window & {
15+
dataLayer: unknown[];
16+
gtag: (...args: unknown[]) => void;
17+
};
18+
w.dataLayer = w.dataLayer || [];
19+
w.gtag = function gtag(...args: unknown[]) {
20+
w.dataLayer.push(args);
21+
};
22+
w.gtag('js', new Date());
23+
w.gtag('config', GA_MEASUREMENT_ID);
24+
}
25+
326
/**
427
* Load third-party scripts (GA gtag.js, Tally widget) after the page is interactive instead of
5-
* during initial load, to keep their JS off the critical path (helps TBT/INP). gtag() calls made
6-
* before the library loads queue in `dataLayer` and flush once it arrives; Tally binds its
7-
* `data-tally-open` buttons on load. Triggers on the first user interaction or when the main
8-
* thread goes idle, whichever comes first. Returns a cleanup function.
28+
* during initial load, to keep their JS off the critical path (helps TBT/INP). The `gtag` shim is
29+
* set up synchronously here so page-view config is queued right away and SPA navigations can call
30+
* `window.gtag`; the heavy libraries load on the first user interaction or when the main thread
31+
* goes idle, whichever comes first. Returns a cleanup function.
932
*/
1033
export function deferThirdPartyScripts(): () => void {
34+
bootstrapGtag();
35+
1136
const sources = [
1237
`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`,
1338
'https://tally.so/widgets/embed.js'

bitext/src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
return deferThirdPartyScripts();
3939
});
4040
41-
/** SPA navigations: initial `enter` is already counted by the snippet in app.html */
41+
/** SPA navigations: initial `enter` is already counted by bootstrapGtag (gtag('config')) */
4242
afterNavigate(({ to, type }) => {
4343
if (!browser || type === 'enter' || !to) return;
4444
const g = (window as Window & { gtag?: (...args: unknown[]) => void }).gtag;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { RequestHandler } from './$types.js';
2+
3+
// Collector for Content-Security-Policy-Report-Only violations. Browsers POST a JSON report here
4+
// (Content-Type application/csp-report or application/reports+json). We log it so violations are
5+
// visible while the policy runs in report-only mode; nothing is stored.
6+
export const POST: RequestHandler = async ({ request }) => {
7+
const body = await request.text();
8+
console.error('[csp-report]', body);
9+
return new Response(null, { status: 204 });
10+
};

bitext/svelte.config.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
import adapter from '@sveltejs/adapter-node';
22

3+
/**
4+
* Content-Security-Policy source allowlist. SvelteKit adds a nonce/hash to script-src on its own
5+
* injected scripts (mode 'auto': nonce for SSR, hash for prerendered), so script-src stays free of
6+
* 'unsafe-inline'. style-src keeps 'unsafe-inline' because the preview relies on dynamic inline
7+
* style attributes; SvelteKit skips adding nonces to any directive that already has 'unsafe-inline'.
8+
*/
9+
const cspDirectives = {
10+
'default-src': ["'self'"],
11+
'script-src': ["'self'", 'https://www.googletagmanager.com', 'https://tally.so'],
12+
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
13+
'font-src': ["'self'", 'data:', 'https://fonts.gstatic.com'],
14+
'img-src': [
15+
"'self'",
16+
'data:',
17+
'blob:',
18+
'https://aligner.fra1.cdn.digitaloceanspaces.com',
19+
'https://www.google-analytics.com'
20+
],
21+
'connect-src': [
22+
"'self'",
23+
'https://www.googletagmanager.com',
24+
'https://*.google-analytics.com',
25+
'https://*.analytics.google.com',
26+
'https://fonts.gstatic.com'
27+
],
28+
'frame-src': ['https://tally.so'],
29+
'frame-ancestors': ["'self'"],
30+
'base-uri': ["'self'"],
31+
'form-action': ["'self'"],
32+
'object-src': ["'none'"],
33+
'report-uri': ['/api/csp-report']
34+
};
35+
336
/** @type {import('@sveltejs/kit').Config} */
437
const config = {
538
compilerOptions: {
@@ -11,6 +44,12 @@ const config = {
1144
/** Canonical / og:url on prerendered routes (examples, privacy). Without this, build bakes `http://sveltekit-prerender/...`. */
1245
prerender: {
1346
origin: 'https://aligner.tinygods.dev'
47+
},
48+
// Report-only first: observe violations via /api/csp-report before enforcing. Flip
49+
// `reportOnly` to `directives` once QA is clean (see the CSP task).
50+
csp: {
51+
mode: 'auto',
52+
reportOnly: cspDirectives
1453
}
1554
}
1655
};

0 commit comments

Comments
 (0)