diff --git a/.changeset/angry-schools-wait.md b/.changeset/angry-schools-wait.md new file mode 100644 index 000000000000..f5c69ddbd174 --- /dev/null +++ b/.changeset/angry-schools-wait.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Add cspNonce config to generate CSP nonces for all scripts and stylesheets. diff --git a/.changeset/twenty-garlics-build.md b/.changeset/twenty-garlics-build.md new file mode 100644 index 000000000000..4435c48571ff --- /dev/null +++ b/.changeset/twenty-garlics-build.md @@ -0,0 +1,8 @@ +--- +'@sveltejs/adapter-cloudflare-workers': patch +'@sveltejs/adapter-netlify': patch +'@sveltejs/adapter-node': patch +'@sveltejs/adapter-vercel': patch +--- + +Add support for generating CSP nonces when `kit.cspNonce` is set. diff --git a/documentation/docs/10-adapters.md b/documentation/docs/10-adapters.md index 27212513f0e5..75f36678d3f6 100644 --- a/documentation/docs/10-adapters.md +++ b/documentation/docs/10-adapters.md @@ -92,6 +92,7 @@ Within the `adapt` method, there are a number of things that an adapter should d - Imports `init` and `render` from `.svelte-kit/output/server/app.js` - Calls `init`, which configures the app - Listens for requests from the platform, converts them to a a [SvelteKit request](#hooks-handle), calls the `render` function to generate a [SvelteKit response](#hooks-handle) and responds with it + - If config.kit.cspNonce is set, it should also generate a base64 cryptographically secure random string with at least 128 bits of entropy for use as a nonce and supply it in the render call. This must be unique for every request. Most platforms support either Node's crypto module or the Web Crypto API. - Globally shims `fetch` to work on the target platform, if necessary. SvelteKit provides a `@sveltejs/kit/install-fetch` helper for platforms that can use `node-fetch` - Bundle the output to avoid needing to install dependencies on the target platform, if desired - Call `utils.prerender` diff --git a/documentation/docs/14-content-security-policy.md b/documentation/docs/14-content-security-policy.md new file mode 100644 index 000000000000..e0aec9ae0e33 --- /dev/null +++ b/documentation/docs/14-content-security-policy.md @@ -0,0 +1,51 @@ +--- +title: Content Security Policy +--- + +At the moment, SvelteKit supports adding Content Security Policy via hooks. In environments with a server-side runtime, HTTP headers can be added to the response object. + +However, SvelteKit also requires some small pieces of inline JavaScript for hydration. To avoid using `'unsafe-inline'` (which, as the name suggests, should be avoided), SvelteKit can be configured to inject CSP nonces into the HTML it generates. + +The nonce value is available to hooks as `request.locals.nonce`. A basic CSP handler hook might then look like this: + +```javascript +export async function handle({ request, resolve }) { + const response = await resolve(request); + + if (response.headers['content-type'] !== 'text/html') { + return response; + } + + const nonce = request.locals.nonce; + + const directives = { + 'default-src': ["'self'", 'static.someotherdomain.com'], + 'script-src': ["'strict-dynamic'", `'nonce-${nonce}'`], + 'style-src': ["'self'", `'nonce-${nonce}'`] + }; + + if (process.env.NODE_ENV === 'development') { + // Because of the way Vite performs hot reloads of stylesheets, + // 'unsafe-inline' is required in dev mode. + 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 + } + }; +} +``` + +Be warned: some other features of Svelte ([in particular CSS transitions and animations](https://github.com/sveltejs/svelte/issues/6662)) might run afoul of this Content Security Policy and require either rewriting to JS-based transitions or enabling `style-src: 'unsafe-inline'`. + +The `'strict-dynamic'` directive is optional but supported by Kit. If not using it you must allow `'self'`. + +The nonce placeholders can be toggled with the `kit.cspNonce` configuration option. Since nonces must be uniquely generated for each request, this also disables prerendering. diff --git a/documentation/docs/14-configuration.md b/documentation/docs/15-configuration.md similarity index 98% rename from documentation/docs/14-configuration.md rename to documentation/docs/15-configuration.md index 2b4691b1057e..ff2430bbc961 100644 --- a/documentation/docs/14-configuration.md +++ b/documentation/docs/15-configuration.md @@ -53,9 +53,10 @@ const config = { ssr: true, target: null, trailingSlash: 'never', + cspNonce: false, vite: () => ({}) }, - + // SvelteKit uses vite-plugin-svelte. Its options can be provided directly here. // See the available options at https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md @@ -219,6 +220,10 @@ Whether to remove, append, or ignore trailing slashes when resolving URLs to rou > Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](#hooks-handle) function. +### cspNonce + +Enables or disables automatically adding [CSP Nonces](#content-security-policy) to your script and style tags. Will also disable prerendering when active. + ### vite A [Vite config object](https://vitejs.dev/config), or a function that returns one. You can pass [Vite and Rollup plugins](https://github.com/vitejs/awesome-vite#plugins) via [the `plugins` option](https://vitejs.dev/config/#plugins) to customize your build in advanced ways such as supporting image optimization, Tauri, WASM, Workbox, and more. SvelteKit will prevent you from setting certain build-related options since it depends on certain configuration values. diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js index f7a7e397a620..688d78f56d31 100644 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -35,7 +35,10 @@ async function handle(event) { query: request_url.searchParams, rawBody: await read(request), headers: Object.fromEntries(request.headers), - method: request.method + method: request.method, + nonce: + /* eslint-disable-line no-undef */ GENERATE_NONCES && + btoa(crypto.getRandomValues(new Uint32Array(2))) }); if (rendered) { diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index 91909812030d..f03485541df7 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -13,7 +13,7 @@ export default function (options) { return { name: '@sveltejs/adapter-cloudflare-workers', - async adapt({ utils }) { + async adapt({ utils, config }) { const { site } = validate_config(utils); const bucket = site.bucket; @@ -39,6 +39,9 @@ export default function (options) { entryPoints: ['.svelte-kit/cloudflare-workers/entry.js'], outfile: `${entrypoint}/index.js`, bundle: true, + define: { + GENERATE_NONCES: config.kit.cspNonce.toString() // gets turned back into a boolean by esbuild + }, target: 'es2020', platform: 'browser' }; diff --git a/packages/adapter-cloudflare/files/worker.js b/packages/adapter-cloudflare/files/worker.js index 8378d9f5add7..f854c692011f 100644 --- a/packages/adapter-cloudflare/files/worker.js +++ b/packages/adapter-cloudflare/files/worker.js @@ -1,4 +1,4 @@ -/* global ASSETS */ +/* global ASSETS, GENERATE_NONCES */ import { init, render } from '../output/server/app.js'; init(); @@ -18,7 +18,8 @@ export default { query: url.searchParams || '', rawBody: await read(req), headers: Object.fromEntries(req.headers), - method: req.method + method: req.method, + nonce: GENERATE_NONCES && btoa(crypto.getRandomValues(new Uint32Array(2))) }); if (rendered) { diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index c0fa58184f96..fdc143b11ccc 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -45,7 +45,10 @@ export default function (options = {}) { outfile: target_worker, allowOverwrite: true, format: 'esm', - bundle: true + bundle: true, + define: { + GENERATE_NONCES: config.kit.cspNonce.toString() + } }); } }; diff --git a/packages/adapter-netlify/files/entry.js b/packages/adapter-netlify/files/entry.js index ebf337948a90..5c3c680c6c6f 100644 --- a/packages/adapter-netlify/files/entry.js +++ b/packages/adapter-netlify/files/entry.js @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; // TODO hardcoding the relative location makes this brittle import { init, render } from '../output/server/app.js'; @@ -16,7 +17,8 @@ export async function handler(event) { headers, path, query, - rawBody + rawBody, + nonce: /* eslint-disable-line no-undef */ GENERATE_NONCES && randomBytes(16).toString('base64') }); if (!rendered) { diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 2368d0f2d1cd..cfdc5c499fa6 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -13,7 +13,7 @@ export default function (options) { return { name: '@sveltejs/adapter-netlify', - async adapt({ utils }) { + async adapt({ utils, config }) { // "build" is the default publish directory when Netlify detects SvelteKit const publish = get_publish_directory(utils) || 'build'; @@ -34,6 +34,9 @@ export default function (options) { outfile: '.netlify/functions-internal/__render.js', bundle: true, inject: [join(files, 'shims.js')], + define: { + GENERATE_NONCES: config.kit.cspNonce.toString() // gets turned back into a boolean by esbuild + }, platform: 'node' }; diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 3d2b258fb6a7..a0cc45dfa21b 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -69,7 +69,8 @@ export default function ({ target: 'node14', inject: [join(files, 'shims.js')], define: { - APP_DIR: `"/${config.kit.appDir}/"` + APP_DIR: `"/${config.kit.appDir}/"`, + GENERATE_NONCES: config.kit.cspNonce.toString() // gets turned back into a boolean by esbuild } }; const build_options = esbuild_config ? await esbuild_config(defaultOptions) : defaultOptions; diff --git a/packages/adapter-node/src/kit-middleware.js b/packages/adapter-node/src/kit-middleware.js index bfa4a3429e48..1a16c7ef6099 100644 --- a/packages/adapter-node/src/kit-middleware.js +++ b/packages/adapter-node/src/kit-middleware.js @@ -1,4 +1,5 @@ import { getRawBody } from '@sveltejs/kit/node'; +import { randomBytes } from 'crypto'; /** * @return {import('polka').Middleware} @@ -29,7 +30,10 @@ export function create_kit_middleware({ render }) { headers: req.headers, // TODO: what about repeated headers, i.e. string[] path: parsed.pathname, query: parsed.searchParams, - rawBody: body + rawBody: body, + nonce: + // @ts-ignore + /* eslint-disable-line no-undef */ GENERATE_NONCES && randomBytes(16).toString('base64') }); if (rendered) { diff --git a/packages/adapter-node/tests/smoke.js b/packages/adapter-node/tests/smoke.js index ed7698959b2b..08e1d52bc5d0 100644 --- a/packages/adapter-node/tests/smoke.js +++ b/packages/adapter-node/tests/smoke.js @@ -6,6 +6,7 @@ import polka from 'polka'; const { PORT = 3000 } = process.env; const DEFAULT_SERVER_OPTS = { render: () => {} }; +globalThis.GENERATE_NONCES = true; // mock. Esbuild inserts this, but we don't esbuild before tests async function startServer(opts = DEFAULT_SERVER_OPTS) { return new Promise((fulfil, reject) => { diff --git a/packages/adapter-vercel/files/entry.js b/packages/adapter-vercel/files/entry.js index 35d6e3170a7c..9db3b3b50ede 100644 --- a/packages/adapter-vercel/files/entry.js +++ b/packages/adapter-vercel/files/entry.js @@ -1,4 +1,5 @@ import { getRawBody } from '@sveltejs/kit/node'; +import { randomBytes } from 'crypto'; // TODO hardcoding the relative location makes this brittle import { init, render } from '../output/server/app.js'; @@ -22,7 +23,8 @@ export default async (req, res) => { headers: req.headers, path: pathname, query: searchParams, - rawBody: body + rawBody: body, + nonce: /* eslint-disable-line no-undef */ GENERATE_NONCES && randomBytes(16).toString('hex') }); if (rendered) { diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index d5c53689f143..a7ee72737dd9 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -12,7 +12,7 @@ export default function (options) { return { name: '@sveltejs/adapter-vercel', - async adapt({ utils }) { + async adapt({ utils, config }) { const dir = '.vercel_build_output'; utils.rimraf(dir); @@ -37,7 +37,10 @@ export default function (options) { outfile: join(dirs.lambda, 'index.js'), bundle: true, inject: [join(files, 'shims.js')], - platform: 'node' + platform: 'node', + define: { + GENERATE_NONCES: config.kit.cspNonce.toString() // gets turned back into a boolean by esbuild + } }; const build_options = diff --git a/packages/kit/src/core/adapt/prerender.js b/packages/kit/src/core/adapt/prerender.js index 97d47cb35142..5ba0dbfa89f7 100644 --- a/packages/kit/src/core/adapt/prerender.js +++ b/packages/kit/src/core/adapt/prerender.js @@ -96,7 +96,7 @@ const REDIRECT = 3; * @returns {Promise>} returns a promise that resolves to an array of paths corresponding to the files that have been prerendered. */ export async function prerender({ cwd, out, log, config, build_data, fallback, all }) { - if (!config.kit.prerender.enabled && !fallback) { + if ((!config.kit.prerender.enabled || config.kit.cspNonce) && !fallback) { return []; } @@ -288,7 +288,7 @@ export async function prerender({ cwd, out, log, config, build_data, fallback, a } } - if (config.kit.prerender.enabled) { + if (config.kit.prerender.enabled && !config.kit.cspNonce) { for (const entry of config.kit.prerender.entries) { if (entry === '*') { for (const entry of build_data.entries) { diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 666a2c42362c..614b5cce7eb0 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -343,7 +343,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)}, + csp_nonce: ${s(config.kit.cspNonce)} }; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 2707b6133c2d..517c930bd42f 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -52,7 +52,8 @@ test('fills in defaults', () => { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + cspNonce: false } }); }); @@ -152,7 +153,8 @@ test('fills in partial blanks', () => { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + cspNonce: false } }); }); diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 1a76bb14e113..1f70cb53809d 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -171,6 +171,8 @@ const options = object( trailingSlash: list(['never', 'always', 'ignore']), + cspNonce: boolean(false), + vite: validate( () => ({}), (input, keypath) => { diff --git a/packages/kit/src/core/config/test/index.js b/packages/kit/src/core/config/test/index.js index 57d966c14909..85f3b727719b 100644 --- a/packages/kit/src/core/config/test/index.js +++ b/packages/kit/src/core/config/test/index.js @@ -54,7 +54,8 @@ async function testLoadDefaultConfig(path) { router: true, ssr: true, target: null, - trailingSlash: 'never' + trailingSlash: 'never', + cspNonce: false } }); } diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index 11e247c3b1fb..e8e570834cec 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -20,6 +20,7 @@ import create_manifest_data from '../create_manifest_data/index.js'; import { getRawBody } from '../node/index.js'; import { SVELTE_KIT, SVELTE_KIT_ASSETS } from '../constants.js'; import { copy_assets, resolve_entry } from '../utils.js'; +import { randomBytes } from 'crypto'; import { coalesce_to_error } from '../../utils/error.js'; /** @typedef {{ cwd?: string, port: number, host?: string, https: boolean, config: import('types/config').ValidatedConfig }} Options */ @@ -385,7 +386,8 @@ async function create_plugin(config, dir, cwd, get_manifest) { host, path: parsed.pathname.replace(config.kit.paths.base, ''), query: parsed.searchParams, - rawBody: body + rawBody: body, + nonce: config.kit.cspNonce && randomBytes(16).toString('base64') }, { amp: config.kit.amp, @@ -510,7 +512,8 @@ async function create_plugin(config, dir, cwd, get_manifest) { return rendered; }, - trailing_slash: config.kit.trailingSlash + trailing_slash: config.kit.trailingSlash, + csp_nonce: config.kit.cspNonce } ); diff --git a/packages/kit/src/core/preview/index.js b/packages/kit/src/core/preview/index.js index 5b1568ecc78b..e204bb60f134 100644 --- a/packages/kit/src/core/preview/index.js +++ b/packages/kit/src/core/preview/index.js @@ -7,6 +7,7 @@ import { pathToFileURL } from 'url'; import { getRawBody } from '../node/index.js'; import { __fetch_polyfill } from '../../install-fetch.js'; import { SVELTE_KIT, SVELTE_KIT_ASSETS } from '../constants.js'; +import { randomBytes } from 'crypto'; /** @param {string} dir */ const mutable = (dir) => @@ -96,7 +97,8 @@ export async function preview({ headers: /** @type {import('types/helper').RequestHeaders} */ (req.headers), path: parsed.pathname.replace(config.kit.paths.base, ''), query: parsed.searchParams, - rawBody: body + rawBody: body, + nonce: config.kit.cspNonce && randomBytes(16).toString('base64') })); if (rendered) { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 52d4f05d5ee3..ca0b42621540 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -32,12 +32,24 @@ export async function respond(incoming, options, state = {}) { } const headers = lowercase_keys(incoming.headers); + /** @type {string | undefined} */ + let nonce; + if (!state.prerender && options.csp_nonce) { + incoming.nonce + ? (nonce = incoming.nonce) + : console.warn( + '`kit.cspNonce` is active, but the adapter did not provide one. Nonces will not be inserted.' + ); + } + const request = { ...incoming, headers, body: parse_body(incoming.rawBody, headers), params: {}, - locals: {} + locals: { + nonce + } }; try { @@ -50,7 +62,8 @@ export async function respond(incoming, options, state = {}) { $session: await options.hooks.getSession(request), page_config: { ssr: false, router: true, hydrate: true }, status: 200, - branch: [] + branch: [], + nonce }); } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index bbd04e773b6e..eec4d558e2c4 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -16,7 +16,8 @@ const s = JSON.stringify; * page_config: { hydrate: boolean, router: boolean, ssr: boolean }; * status: number; * error?: Error, - * page?: import('types/page').Page + * page?: import('types/page').Page, + * nonce?: string * }} opts */ export async function render_response({ @@ -26,11 +27,13 @@ export async function render_response({ page_config, status, error, - page + page, + nonce }) { const css = new Set(options.entry.css); const js = new Set(options.entry.js); const styles = new Set(); + nonce = options.csp_nonce && nonce ? `nonce="${nonce}"` : ''; /** @type {Array<{ url: string, body: string, json: string }>} */ const serialized_data = []; @@ -101,8 +104,8 @@ export async function render_response({ ? `` : '' : [ - ...Array.from(js).map((dep) => ``), - ...Array.from(css).map((dep) => ``) + ...Array.from(js).map((dep) => ``), + ...Array.from(css).map((dep) => ``) ].join('\n\t\t'); /** @type {string} */ @@ -118,7 +121,7 @@ export async function render_response({ : ''; } else if (include_js) { // prettier-ignore - init = ``; + return ``; }) .join('\n\n\t')} `; diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 1e79cd504bc0..5ca0805349fb 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -200,7 +200,8 @@ export async function respond(opts) { page_config, status, error, - branch: branch.filter(Boolean) + branch: branch.filter(Boolean), + nonce: request.locals.nonce }), set_cookie_headers ); diff --git a/packages/kit/types/app.d.ts b/packages/kit/types/app.d.ts index 0fbe02bd9d7f..cf8ae5164e0a 100644 --- a/packages/kit/types/app.d.ts +++ b/packages/kit/types/app.d.ts @@ -18,4 +18,5 @@ export interface IncomingRequest { query: URLSearchParams; headers: RequestHeaders; rawBody: RawBody; + nonce?: string | false; } diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 5d2214d0eacb..2be1f037ac5d 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -88,6 +88,7 @@ export interface Config { ssr?: boolean; target?: string; trailingSlash?: TrailingSlash; + cspNonce?: boolean; vite?: ViteConfig | (() => ViteConfig); }; preprocess?: any; diff --git a/packages/kit/types/globals.d.ts b/packages/kit/types/globals.d.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index bfc5b5ab0a05..53d64f2edba5 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -159,6 +159,7 @@ export interface SSRRenderOptions { target: string; template({ head, body }: { head: string; body: string }): string; trailing_slash: TrailingSlash; + csp_nonce: boolean; } export interface SSRRenderState {