Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] add CSP nonces to script/style tags #2394

Closed
wants to merge 41 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
bc57637
Add nonce generation to request handling.
Karlinator Sep 9, 2021
c1f00ec
Changeset
Karlinator Sep 9, 2021
03cc653
[docs] document CSP Nonces.
Karlinator Sep 9, 2021
6e723ed
Use CSP nonce generator shimmed by the adapter.
Karlinator Sep 10, 2021
ed86bab
Add CspNonceGenerator shim to adapter-node.
Karlinator Sep 10, 2021
8003b51
Add CspNonceGenerator shim to dev.
Karlinator Sep 10, 2021
57ade88
Add typing for nonce generator.
Karlinator Sep 10, 2021
e5c3f18
Fix failing test case.
Karlinator Sep 10, 2021
e886aed
[docs] Apply suggestions from code review
Karlinator Sep 10, 2021
dec6a4c
Add generateCspNonce shim to all adapters.
Karlinator Sep 10, 2021
a557b47
Add changeset for adapters
Karlinator Sep 10, 2021
416d86e
Fix deendency error in adapters.
Karlinator Sep 10, 2021
fc0fd83
Add error handling for missing generateCspNonce.
Karlinator Sep 10, 2021
03579da
Fix which tags get nonces.
Karlinator Sep 10, 2021
3d4ac9c
Fix lint error.
Karlinator Sep 10, 2021
3ee9281
[docs] fix syntax error in CSP code example.
Karlinator Sep 10, 2021
7c137a1
Change adapter nonce API to be more generic.
Karlinator Sep 11, 2021
c98de8a
[docs] Document adapter changes required by #2394
Karlinator Sep 12, 2021
bcbda45
[docs] Improve CSP hook example.
Karlinator Sep 12, 2021
449a456
Fix injecting the shims.
Karlinator Sep 12, 2021
d873cac
Change nonce to be generated/supplied by adapter
Karlinator Sep 13, 2021
ecb23aa
Add nonces to preview server
Karlinator Sep 13, 2021
d906254
Fix test case
Karlinator Sep 13, 2021
c9c9736
Update cahngeset message for adapters.
Karlinator Sep 14, 2021
3a2b756
Remove disused file.
Karlinator Sep 20, 2021
8826933
Implement suggestion from code review.
Karlinator Oct 18, 2021
18e08ca
[Docs] Suggestions from code review.
Karlinator Oct 18, 2021
9ff68e6
[docs] Update documentation/docs/14-content-security-policy.md
Karlinator Sep 28, 2021
cd269ac
[docs] Apply suggestions from code review
Karlinator Sep 28, 2021
449bd51
Fix misnamed option.
Karlinator Oct 19, 2021
4ec250f
Disable prerendering if nonces are to be generated
Karlinator Nov 3, 2021
a13c758
Fix adapter-node nonce generation
Karlinator Nov 3, 2021
3899598
Fix Cloudflare workers nonce generation
Karlinator Nov 3, 2021
b2a35cc
Fix netlify nonce generation
Karlinator Nov 3, 2021
51a0ba1
Fix Vercel adapter nonce generation
Karlinator Nov 3, 2021
eb081b6
[docs] clarify `cspNonce` disables prerendering
Karlinator Dec 13, 2021
c81dc3a
Change adapter nonce API to be more generic.
Karlinator Sep 11, 2021
d5cc05b
Fix injecting the shims.
Karlinator Sep 12, 2021
a7b8003
Change nonce to be generated/supplied by adapter
Karlinator Sep 13, 2021
7e693d5
Implement suggestion from code review.
Karlinator Oct 18, 2021
cf26271
Add nonces to adapter-cloudflare.
Karlinator Jan 8, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angry-schools-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Add cspNonce config to generate CSP nonces for all scripts and stylesheets.
8 changes: 8 additions & 0 deletions .changeset/twenty-garlics-build.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions documentation/docs/10-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
51 changes: 51 additions & 0 deletions documentation/docs/14-content-security-policy.md
Original file line number Diff line number Diff line change
@@ -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'`.
Karlinator marked this conversation as resolved.
Show resolved Hide resolved

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
5 changes: 4 additions & 1 deletion packages/adapter-cloudflare-workers/files/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/adapter-cloudflare-workers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'
};
Expand Down
5 changes: 3 additions & 2 deletions packages/adapter-cloudflare/files/worker.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global ASSETS */
/* global ASSETS, GENERATE_NONCES */
import { init, render } from '../output/server/app.js';

init();
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
});
}
};
Expand Down
4 changes: 3 additions & 1 deletion packages/adapter-netlify/files/entry.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomBytes } from 'crypto';
// TODO hardcoding the relative location makes this brittle
import { init, render } from '../output/server/app.js';

Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/adapter-netlify/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'
};

Expand Down
3 changes: 2 additions & 1 deletion packages/adapter-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/adapter-node/src/kit-middleware.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getRawBody } from '@sveltejs/kit/node';
import { randomBytes } from 'crypto';

/**
* @return {import('polka').Middleware}
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-node/tests/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/adapter-vercel/files/entry.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 =
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/core/adapt/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const REDIRECT = 3;
* @returns {Promise<Array<string>>} 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 [];
}

Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
};
}

Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ test('fills in defaults', () => {
router: true,
ssr: true,
target: null,
trailingSlash: 'never'
trailingSlash: 'never',
cspNonce: false
}
});
});
Expand Down Expand Up @@ -152,7 +153,8 @@ test('fills in partial blanks', () => {
router: true,
ssr: true,
target: null,
trailingSlash: 'never'
trailingSlash: 'never',
cspNonce: false
}
});
});
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ const options = object(

trailingSlash: list(['never', 'always', 'ignore']),

cspNonce: boolean(false),

vite: validate(
() => ({}),
(input, keypath) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ async function testLoadDefaultConfig(path) {
router: true,
ssr: true,
target: null,
trailingSlash: 'never'
trailingSlash: 'never',
cspNonce: false
}
});
}
Expand Down
7 changes: 5 additions & 2 deletions packages/kit/src/core/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
);

Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/core/preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand Down
Loading