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 {