diff --git a/.changeset/lovely-rats-cheer.md b/.changeset/lovely-rats-cheer.md new file mode 100644 index 000000000000..18ba7055d0c7 --- /dev/null +++ b/.changeset/lovely-rats-cheer.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Add option to place CSP nonce placeholders in rendered pages. diff --git a/documentation/docs/15-content-security-policy.md b/documentation/docs/15-content-security-policy.md new file mode 100644 index 000000000000..a404e7d1d3fe --- /dev/null +++ b/documentation/docs/15-content-security-policy.md @@ -0,0 +1,49 @@ +--- +title: Content Security Policy +--- + +At the moment, SvelteKit supports adding Content Security Policy via hooks. In environments with a runtime, HTTP headers can be added to the response object. + +However, SvelteKit also requires some small pieces of inline JavaScript in order for hydration to work. To avoid using `'unsafe-inline'` (which, as the name suggests, should be avoided), SvelteKit can be configured to inject place-holders for CSP Nonces into the html it generates. + +These placeholders take the form `%svelte.CSPNonce%`, and can be replaced with a nonce inside the hook. A basic CSP handler hook might then look like this: +```javascript +export async function handle ({ request, resolve }) => { + const directives = { + 'default-src': ["'self'", rootDomain, `ws://${rootDomain}`], + 'script-src': ["'strict-dynamic'"], + 'style-src': ["'self'"] + }; + + const response = await resolve(request); + + const nonce = randomBytes(32).toString('base64'); + + if (response.headers['content-type'] === 'text/html') { + response.body = (response.body as string).replace(/%svelte.CSPNonce%/g, `nonce="${nonce}"`); + } + directives['script-src'].push(`'nonce-${nonce}'`); + directives['style-src'].push(`'nonce-${nonce}'`); + + if (process.env.NODE_ENV === 'development') { + directives['style-src'].push('unsafe-inline') + } + + const csp = Object.entries(directives) + .map(([key, arr]) => key + ' ' + arr.join(' ')) + .join('; '); + + return { + ...response, + headers: { + ...response.headers, + 'Content-Security-Policy': csp + } + }; +}; +``` +Because of the way Vite performs hot reloads of stylesheets, `'unsafe-inline'` is required in dev mode. + +Be warned: some other features of Svelte (in particular CSS transitions and animations) might run afoul of this Content Security Policy and require either rewriting to JS-based transitions or enabling `style-src: 'unsafe-inline'`. + +The nonce placeholders can be toggled with the `kit.noncePlaceholders` configuration option. diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 7ea97b239612..a9498a3a5057 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -349,7 +349,8 @@ async function build_server( ssr: ${s(config.kit.ssr)}, target: ${s(config.kit.target)}, template, - trailing_slash: ${s(config.kit.trailingSlash)} + trailing_slash: ${s(config.kit.trailingSlash)}, + noncePlaceholders: ${s(config.kit.noncePlaceholders)} }; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index f110463fa9b2..49319887e0ba 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -56,7 +56,8 @@ test('fills in defaults', () => { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + noncePlaceholders: false } }); }); @@ -164,7 +165,8 @@ test('fills in partial blanks', () => { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + noncePlaceholders: false } }); }); diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 213064e42e45..9f226ac36481 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -192,7 +192,9 @@ const options = object( return input; } - ) + ), + + noncePlaceholders: boolean(false) }) }, true diff --git a/packages/kit/src/core/config/test/index.js b/packages/kit/src/core/config/test/index.js index 3d7f7dfc9714..32ab3b198af2 100644 --- a/packages/kit/src/core/config/test/index.js +++ b/packages/kit/src/core/config/test/index.js @@ -63,7 +63,8 @@ async function testLoadDefaultConfig(path) { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + noncePlaceholders: false } }); } diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 06253e6919e4..1d5ea86be19d 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -474,7 +474,8 @@ async function create_handler(vite, config, dir, cwd, get_manifest) { return rendered; }, - trailing_slash: config.kit.trailingSlash + trailing_slash: config.kit.trailingSlash, + noncePlaceholders: config.kit.noncePlaceholders } ); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index dc3f9d247834..f147740744ef 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -29,6 +29,7 @@ export async function render_response({ const css = new Set(options.entry.css); const js = new Set(options.entry.js); const styles = new Set(); + const nonce = options.noncePlaceholders ? '%svelte.CSPNonce%' : ''; /** @type {Array<{ url: string, body: string, json: string }>} */ const serialized_data = []; @@ -96,10 +97,12 @@ export async function render_response({ // TODO strip the AMP stuff out of the build if not relevant const links = options.amp ? styles.size > 0 || rendered.css.code.length > 0 - ? `` + ? `` : '' : [ - ...Array.from(js).map((dep) => ``), + ...Array.from(js).map((dep) => ``), ...Array.from(css).map((dep) => ``) ].join('\n\t\t'); @@ -108,12 +111,12 @@ export async function render_response({ if (options.amp) { init = ` - + `; } else if (include_js) { // prettier-ignore - init = ``; + return ``; }) .join('\n\n\t')} `; diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 3ff95f46fbb2..5983866b8fbc 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -78,6 +78,7 @@ export interface Config { target?: string; trailingSlash?: TrailingSlash; vite?: ViteConfig | (() => ViteConfig); + noncePlaceholders?: boolean; }; preprocess?: any; } diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index fd7864c1acac..3879d6720f33 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -136,6 +136,7 @@ export interface SSRRenderOptions { target: string; template({ head, body }: { head: string; body: string }): string; trailing_slash: TrailingSlash; + noncePlaceholders: boolean; } export interface SSRRenderState {