Skip to content

Commit c86c4fd

Browse files
feat: Add CSP support for hydratable (#17338)
1 parent 5f249ab commit c86c4fd

File tree

23 files changed

+294
-30
lines changed

23 files changed

+294
-30
lines changed

.changeset/soft-donkeys-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": minor
3+
---
4+
5+
feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable`

documentation/docs/06-runtime/05-hydratable.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,61 @@ All data returned from a `hydratable` function must be serializable. But this do
6363
{await promises.one}
6464
{await promises.two}
6565
```
66+
67+
## CSP
68+
69+
`hydratable` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
70+
71+
```js
72+
/// file: server.js
73+
import { render } from 'svelte/server';
74+
import App from './App.svelte';
75+
// ---cut---
76+
const nonce = crypto.randomUUID();
77+
78+
const { head, body } = await render(App, {
79+
csp: { nonce }
80+
});
81+
```
82+
83+
This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:
84+
85+
```js
86+
/// file: server.js
87+
let response = new Response();
88+
let nonce = 'xyz123';
89+
// ---cut---
90+
response.headers.set(
91+
'Content-Security-Policy',
92+
`script-src 'nonce-${nonce}'`
93+
);
94+
```
95+
96+
It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.
97+
98+
If instead you are generating static HTML ahead of time, you must use hashes instead:
99+
100+
```js
101+
/// file: server.js
102+
import { render } from 'svelte/server';
103+
import App from './App.svelte';
104+
// ---cut---
105+
const { head, body, hashes } = await render(App, {
106+
csp: { hash: true }
107+
});
108+
```
109+
110+
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
111+
112+
```js
113+
/// file: server.js
114+
let response = new Response();
115+
let hashes = { script: ['sha256-xyz123'] };
116+
// ---cut---
117+
response.headers.set(
118+
'Content-Security-Policy',
119+
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
120+
);
121+
```
122+
123+
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.

documentation/docs/98-reference/.generated/server-errors.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ Cause:
5555
%stack%
5656
```
5757

58+
### invalid_csp
59+
60+
```
61+
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
62+
```
63+
5864
### lifecycle_function_unavailable
5965

6066
```

packages/svelte/messages/server-errors/errors.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
4343
> Cause:
4444
> %stack%
4545
46+
## invalid_csp
47+
48+
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
49+
4650
## lifecycle_function_unavailable
4751

4852
> `%name%(...)` is not available on the server
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { BROWSER } from 'esm-env';
2+
3+
let text_encoder;
4+
// TODO - remove this and use global `crypto` when we drop Node 18
5+
let crypto;
6+
7+
/** @param {string} data */
8+
export async function sha256(data) {
9+
text_encoder ??= new TextEncoder();
10+
11+
// @ts-expect-error
12+
crypto ??= globalThis.crypto?.subtle?.digest
13+
? globalThis.crypto
14+
: // @ts-ignore - we don't install node types in the prod build
15+
(await import('node:crypto')).webcrypto;
16+
17+
const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));
18+
19+
return base64_encode(hash_buffer);
20+
}
21+
22+
/**
23+
* @param {Uint8Array} bytes
24+
* @returns {string}
25+
*/
26+
export function base64_encode(bytes) {
27+
// Using `Buffer` is faster than iterating
28+
// @ts-ignore
29+
if (!BROWSER && globalThis.Buffer) {
30+
// @ts-ignore
31+
return globalThis.Buffer.from(bytes).toString('base64');
32+
}
33+
34+
let binary = '';
35+
36+
for (let i = 0; i < bytes.length; i++) {
37+
binary += String.fromCharCode(bytes[i]);
38+
}
39+
40+
return btoa(binary);
41+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { assert, test } from 'vitest';
2+
import { sha256 } from './crypto.js';
3+
4+
const inputs = [
5+
['hello world', 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='],
6+
['', '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='],
7+
['abcd', 'iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk='],
8+
['the quick brown fox jumps over the lazy dog', 'Bcbgjx2f2voDFH/Lj4LxJMdtL3Dj2Ynciq2159dFC+w='],
9+
['工欲善其事,必先利其器', 'oPOthkQ1c5BbPpvrr5WlUBJPyD5e6JeVdWcqBs9zvjA=']
10+
];
11+
12+
test.each(inputs)('sha256("%s")', async (input, expected) => {
13+
const actual = await sha256(input);
14+
assert.equal(actual, expected);
15+
});

packages/svelte/src/internal/server/errors.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ ${stack}\nhttps://svelte.dev/e/hydratable_serialization_failed`);
8080
throw error;
8181
}
8282

83+
/**
84+
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
85+
* @returns {never}
86+
*/
87+
export function invalid_csp() {
88+
const error = new Error(`invalid_csp\n\`csp.nonce\` was set while \`csp.hash\` was \`true\`. These options cannot be used simultaneously.\nhttps://svelte.dev/e/invalid_csp`);
89+
90+
error.name = 'Svelte error';
91+
92+
throw error;
93+
}
94+
8395
/**
8496
* `%name%(...)` is not available on the server
8597
* @param {string} name

packages/svelte/src/internal/server/index.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
2-
/** @import { RenderOutput } from '#server' */
2+
/** @import { Csp, RenderOutput } from '#server' */
33
/** @import { Store } from '#shared' */
4-
/** @import { AccumulatedContent } from './renderer.js' */
54
export { FILENAME, HMR } from '../../constants.js';
65
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
76
import { is_promise, noop } from '../shared/utils.js';
@@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
1817
import { validate_store } from '../shared/validate.js';
1918
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
2019
import { Renderer } from './renderer.js';
20+
import * as e from './errors.js';
2121

2222
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
2323
// https://infra.spec.whatwg.org/#noncharacter
@@ -56,10 +56,13 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
5656
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
5757
* @template {Record<string, any>} Props
5858
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
59-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
59+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
6060
* @returns {RenderOutput}
6161
*/
6262
export function render(component, options = {}) {
63+
if (options.csp?.hash && options.csp.nonce) {
64+
e.invalid_csp();
65+
}
6366
return Renderer.render(/** @type {Component<Props>} */ (component), options);
6467
}
6568

packages/svelte/src/internal/server/renderer.js

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @import { Component } from 'svelte' */
2-
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
2+
/** @import { Csp, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput, Sha256Source } from './types.js' */
33
/** @import { MaybePromise } from '#shared' */
44
import { async_mode_flag } from '../flags/index.js';
55
import { abort } from './abort-signal.js';
@@ -9,7 +9,7 @@ import * as w from './warnings.js';
99
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
1010
import { attributes } from './index.js';
1111
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
12-
import { DEV } from 'esm-env';
12+
import { sha256 } from './crypto.js';
1313

1414
/** @typedef {'head' | 'body'} RendererType */
1515
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@@ -376,13 +376,13 @@ export class Renderer {
376376
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
377377
* @template {Record<string, any>} Props
378378
* @param {Component<Props>} component
379-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
379+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
380380
* @returns {RenderOutput}
381381
*/
382382
static render(component, options = {}) {
383383
/** @type {AccumulatedContent | undefined} */
384384
let sync;
385-
/** @type {Promise<AccumulatedContent> | undefined} */
385+
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
386386
let async;
387387

388388
const result = /** @type {RenderOutput} */ ({});
@@ -404,6 +404,11 @@ export class Renderer {
404404
return (sync ??= Renderer.#render(component, options)).body;
405405
}
406406
},
407+
hashes: {
408+
value: {
409+
script: ''
410+
}
411+
},
407412
then: {
408413
value:
409414
/**
@@ -420,7 +425,8 @@ export class Renderer {
420425
const user_result = onfulfilled({
421426
head: result.head,
422427
body: result.body,
423-
html: result.body
428+
html: result.body,
429+
hashes: { script: [] }
424430
});
425431
return Promise.resolve(user_result);
426432
}
@@ -514,8 +520,8 @@ export class Renderer {
514520
*
515521
* @template {Record<string, any>} Props
516522
* @param {Component<Props>} component
517-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
518-
* @returns {Promise<AccumulatedContent>}
523+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
524+
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
519525
*/
520526
static async #render_async(component, options) {
521527
const previous_context = ssr_context;
@@ -585,19 +591,19 @@ export class Renderer {
585591
await comparison;
586592
}
587593

588-
return await Renderer.#hydratable_block(ctx);
594+
return await this.#hydratable_block(ctx);
589595
}
590596

591597
/**
592598
* @template {Record<string, any>} Props
593599
* @param {'sync' | 'async'} mode
594600
* @param {import('svelte').Component<Props>} component
595-
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
601+
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
596602
* @returns {Renderer}
597603
*/
598604
static #open_render(mode, component, options) {
599605
const renderer = new Renderer(
600-
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '')
606+
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
601607
);
602608

603609
renderer.push(BLOCK_OPEN);
@@ -623,6 +629,7 @@ export class Renderer {
623629
/**
624630
* @param {AccumulatedContent} content
625631
* @param {Renderer} renderer
632+
* @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }}
626633
*/
627634
static #close_render(content, renderer) {
628635
for (const cleanup of renderer.#collect_on_destroy()) {
@@ -638,14 +645,17 @@ export class Renderer {
638645

639646
return {
640647
head,
641-
body
648+
body,
649+
hashes: {
650+
script: renderer.global.csp.script_hashes
651+
}
642652
};
643653
}
644654

645655
/**
646656
* @param {HydratableContext} ctx
647657
*/
648-
static async #hydratable_block(ctx) {
658+
async #hydratable_block(ctx) {
649659
if (ctx.lookup.size === 0) {
650660
return null;
651661
}
@@ -669,9 +679,7 @@ export class Renderer {
669679
${prelude}`;
670680
}
671681

672-
// TODO csp -- have discussed but not implemented
673-
return `
674-
<script>
682+
const body = `
675683
{
676684
${prelude}
677685
@@ -681,11 +689,27 @@ export class Renderer {
681689
h.set(k, v);
682690
}
683691
}
684-
</script>`;
692+
`;
693+
694+
let csp_attr = '';
695+
if (this.global.csp.nonce) {
696+
csp_attr = ` nonce="${this.global.csp.nonce}"`;
697+
} else if (this.global.csp.hash) {
698+
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
699+
// because the it's impossible for identical data to occur multiple times in a single render
700+
// (this would require the same hydratable key:value pair to be serialized multiple times)
701+
const hash = await sha256(body);
702+
this.global.csp.script_hashes.push(`sha256-${hash}`);
703+
}
704+
705+
return `\n\t\t<script${csp_attr}>${body}</script>`;
685706
}
686707
}
687708

688709
export class SSRState {
710+
/** @readonly @type {Csp & { script_hashes: Sha256Source[] }} */
711+
csp;
712+
689713
/** @readonly @type {'sync' | 'async'} */
690714
mode;
691715

@@ -700,10 +724,12 @@ export class SSRState {
700724

701725
/**
702726
* @param {'sync' | 'async'} mode
703-
* @param {string} [id_prefix]
727+
* @param {string} id_prefix
728+
* @param {Csp} csp
704729
*/
705-
constructor(mode, id_prefix = '') {
730+
constructor(mode, id_prefix = '', csp = { hash: false }) {
706731
this.mode = mode;
732+
this.csp = { ...csp, script_hashes: [] };
707733

708734
let uid = 1;
709735
this.uid = () => `${id_prefix}s${uid++}`;

packages/svelte/src/internal/server/types.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface SSRContext {
1515
element?: Element;
1616
}
1717

18+
export type Csp = { nonce?: string; hash?: boolean };
19+
1820
export interface HydratableLookupEntry {
1921
value: unknown;
2022
serialized: string;
@@ -33,13 +35,18 @@ export interface RenderContext {
3335
hydratable: HydratableContext;
3436
}
3537

38+
export type Sha256Source = `sha256-${string}`;
39+
3640
export interface SyncRenderOutput {
3741
/** HTML that goes into the `<head>` */
3842
head: string;
3943
/** @deprecated use `body` instead */
4044
html: string;
4145
/** HTML that goes somewhere into the `<body>` */
4246
body: string;
47+
hashes: {
48+
script: Sha256Source[];
49+
};
4350
}
4451

4552
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

0 commit comments

Comments
 (0)