feat: Add CSP support for `hydratable` (#17338)

pull/17358/head
Elliott Johnson 1 week ago committed by GitHub
parent 5f249abeae
commit c86c4fdca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": minor
---
feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable`

@ -63,3 +63,61 @@ All data returned from a `hydratable` function must be serializable. But this do
{await promises.one}
{await promises.two}
```
## CSP
`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`:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const nonce = crypto.randomUUID();
const { head, body } = await render(App, {
csp: { nonce }
});
```
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:
```js
/// file: server.js
let response = new Response();
let nonce = 'xyz123';
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
```
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.
If instead you are generating static HTML ahead of time, you must use hashes instead:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
// ---cut---
const { head, body, hashes } = await render(App, {
csp: { hash: true }
});
```
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
```js
/// file: server.js
let response = new Response();
let hashes = { script: ['sha256-xyz123'] };
// ---cut---
response.headers.set(
'Content-Security-Policy',
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
);
```
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.

@ -55,6 +55,12 @@ Cause:
%stack%
```
### invalid_csp
```
`csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
```
### lifecycle_function_unavailable
```

@ -43,6 +43,10 @@ This error occurs when using `hydratable` multiple times with the same key. To a
> Cause:
> %stack%
## invalid_csp
> `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
## lifecycle_function_unavailable
> `%name%(...)` is not available on the server

@ -0,0 +1,41 @@
import { BROWSER } from 'esm-env';
let text_encoder;
// TODO - remove this and use global `crypto` when we drop Node 18
let crypto;
/** @param {string} data */
export async function sha256(data) {
text_encoder ??= new TextEncoder();
// @ts-expect-error
crypto ??= globalThis.crypto?.subtle?.digest
? globalThis.crypto
: // @ts-ignore - we don't install node types in the prod build
(await import('node:crypto')).webcrypto;
const hash_buffer = await crypto.subtle.digest('SHA-256', text_encoder.encode(data));
return base64_encode(hash_buffer);
}
/**
* @param {Uint8Array} bytes
* @returns {string}
*/
export function base64_encode(bytes) {
// Using `Buffer` is faster than iterating
// @ts-ignore
if (!BROWSER && globalThis.Buffer) {
// @ts-ignore
return globalThis.Buffer.from(bytes).toString('base64');
}
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

@ -0,0 +1,15 @@
import { assert, test } from 'vitest';
import { sha256 } from './crypto.js';
const inputs = [
['hello world', 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='],
['', '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='],
['abcd', 'iNQmb9TmM40TuEX88olXnSCciXgjuSF9o+Fhk28DFYk='],
['the quick brown fox jumps over the lazy dog', 'Bcbgjx2f2voDFH/Lj4LxJMdtL3Dj2Ynciq2159dFC+w='],
['工欲善其事,必先利其器', 'oPOthkQ1c5BbPpvrr5WlUBJPyD5e6JeVdWcqBs9zvjA=']
];
test.each(inputs)('sha256("%s")', async (input, expected) => {
const actual = await sha256(input);
assert.equal(actual, expected);
});

@ -80,6 +80,18 @@ ${stack}\nhttps://svelte.dev/e/hydratable_serialization_failed`);
throw error;
}
/**
* `csp.nonce` was set while `csp.hash` was `true`. These options cannot be used simultaneously.
* @returns {never}
*/
export function invalid_csp() {
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`);
error.name = 'Svelte error';
throw error;
}
/**
* `%name%(...)` is not available on the server
* @param {string} name

@ -1,7 +1,6 @@
/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */
/** @import { RenderOutput } from '#server' */
/** @import { Csp, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */
/** @import { AccumulatedContent } from './renderer.js' */
export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js';
@ -18,6 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { Renderer } from './renderer.js';
import * as e from './errors.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter
@ -56,10 +56,13 @@ export function element(renderer, tag, attributes_fn = noop, children_fn = noop)
* 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.
* @template {Record<string, any>} Props
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @returns {RenderOutput}
*/
export function render(component, options = {}) {
if (options.csp?.hash && options.csp.nonce) {
e.invalid_csp();
}
return Renderer.render(/** @type {Component<Props>} */ (component), options);
}

@ -1,5 +1,5 @@
/** @import { Component } from 'svelte' */
/** @import { HydratableContext, RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { Csp, HydratableContext, RenderOutput, SSRContext, SyncRenderOutput, Sha256Source } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.js';
@ -9,7 +9,7 @@ import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { DEV } from 'esm-env';
import { sha256 } from './crypto.js';
/** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@ -376,13 +376,13 @@ export class Renderer {
* 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.
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} [options]
* @returns {RenderOutput}
*/
static render(component, options = {}) {
/** @type {AccumulatedContent | undefined} */
let sync;
/** @type {Promise<AccumulatedContent> | undefined} */
/** @type {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }> | undefined} */
let async;
const result = /** @type {RenderOutput} */ ({});
@ -404,6 +404,11 @@ export class Renderer {
return (sync ??= Renderer.#render(component, options)).body;
}
},
hashes: {
value: {
script: ''
}
},
then: {
value:
/**
@ -420,7 +425,8 @@ export class Renderer {
const user_result = onfulfilled({
head: result.head,
body: result.body,
html: result.body
html: result.body,
hashes: { script: [] }
});
return Promise.resolve(user_result);
}
@ -514,8 +520,8 @@ export class Renderer {
*
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @returns {Promise<AccumulatedContent>}
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @returns {Promise<AccumulatedContent & { hashes: { script: Sha256Source[] } }>}
*/
static async #render_async(component, options) {
const previous_context = ssr_context;
@ -585,19 +591,19 @@ export class Renderer {
await comparison;
}
return await Renderer.#hydratable_block(ctx);
return await this.#hydratable_block(ctx);
}
/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
* @param {import('svelte').Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string; csp?: Csp }} options
* @returns {Renderer}
*/
static #open_render(mode, component, options) {
const renderer = new Renderer(
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '')
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '', options.csp)
);
renderer.push(BLOCK_OPEN);
@ -623,6 +629,7 @@ export class Renderer {
/**
* @param {AccumulatedContent} content
* @param {Renderer} renderer
* @returns {AccumulatedContent & { hashes: { script: Sha256Source[] } }}
*/
static #close_render(content, renderer) {
for (const cleanup of renderer.#collect_on_destroy()) {
@ -638,14 +645,17 @@ export class Renderer {
return {
head,
body
body,
hashes: {
script: renderer.global.csp.script_hashes
}
};
}
/**
* @param {HydratableContext} ctx
*/
static async #hydratable_block(ctx) {
async #hydratable_block(ctx) {
if (ctx.lookup.size === 0) {
return null;
}
@ -669,9 +679,7 @@ export class Renderer {
${prelude}`;
}
// TODO csp -- have discussed but not implemented
return `
<script>
const body = `
{
${prelude}
@ -681,11 +689,27 @@ export class Renderer {
h.set(k, v);
}
}
</script>`;
`;
let csp_attr = '';
if (this.global.csp.nonce) {
csp_attr = ` nonce="${this.global.csp.nonce}"`;
} else if (this.global.csp.hash) {
// note to future selves: this doesn't need to be optimized with a Map<body, hash>
// because the it's impossible for identical data to occur multiple times in a single render
// (this would require the same hydratable key:value pair to be serialized multiple times)
const hash = await sha256(body);
this.global.csp.script_hashes.push(`sha256-${hash}`);
}
return `\n\t\t<script${csp_attr}>${body}</script>`;
}
}
export class SSRState {
/** @readonly @type {Csp & { script_hashes: Sha256Source[] }} */
csp;
/** @readonly @type {'sync' | 'async'} */
mode;
@ -700,10 +724,12 @@ export class SSRState {
/**
* @param {'sync' | 'async'} mode
* @param {string} [id_prefix]
* @param {string} id_prefix
* @param {Csp} csp
*/
constructor(mode, id_prefix = '') {
constructor(mode, id_prefix = '', csp = { hash: false }) {
this.mode = mode;
this.csp = { ...csp, script_hashes: [] };
let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`;

@ -15,6 +15,8 @@ export interface SSRContext {
element?: Element;
}
export type Csp = { nonce?: string; hash?: boolean };
export interface HydratableLookupEntry {
value: unknown;
serialized: string;
@ -33,6 +35,8 @@ export interface RenderContext {
hydratable: HydratableContext;
}
export type Sha256Source = `sha256-${string}`;
export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;
@ -40,6 +44,9 @@ export interface SyncRenderOutput {
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
hashes: {
script: Sha256Source[];
};
}
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

@ -1,14 +1,14 @@
/** @import { SvelteComponent } from '../index.js' */
/** @import { Csp } from '#server' */
import { asClassComponent as as_class_component, createClassComponent } from './legacy-client.js';
import { render } from '../internal/server/index.js';
import { async_mode_flag } from '../internal/flags/index.js';
import * as w from '../internal/server/warnings.js';
// By having this as a separate entry point for server environments, we save the client bundle from having to include the server runtime
export { createClassComponent };
/** @typedef {{ head: string, html: string, css: { code: string, map: null }}} LegacyRenderResult */
/** @typedef {{ head: string, html: string, css: { code: string, map: null }; hashes?: { script: `sha256-${string}`[] } }} LegacyRenderResult */
/**
* Takes a Svelte 5 component and returns a Svelte 4 compatible component constructor.
@ -25,10 +25,10 @@ export { createClassComponent };
*/
export function asClassComponent(component) {
const component_constructor = as_class_component(component);
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */
const _render = (props, { context } = {}) => {
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; csp?: Csp }) => LegacyRenderResult & PromiseLike<LegacyRenderResult> } */
const _render = (props, { context, csp } = {}) => {
// @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode
const result = render(component, { props, context });
const result = render(component, { props, context, csp });
const munged = Object.defineProperties(
/** @type {LegacyRenderResult & PromiseLike<LegacyRenderResult>} */ ({}),
@ -65,7 +65,8 @@ export function asClassComponent(component) {
return onfulfilled({
css: munged.css,
head: result.head,
html: result.body
html: result.body,
hashes: result.hashes
});
}, onrejected);
}

@ -1,4 +1,4 @@
import type { RenderOutput } from '#server';
import type { Csp, RenderOutput } from '#server';
import type { ComponentProps, Component, SvelteComponent, ComponentType } from 'svelte';
/**
@ -16,6 +16,7 @@ export function render<
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
}
]
: [
@ -24,6 +25,7 @@ export function render<
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
}
]
): RenderOutput;

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true, nonce: 'test-nonce' },
error: 'invalid_csp'
});

@ -0,0 +1,12 @@
<script nonce="test-nonce">
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { hash: true },
script_hashes: ['sha256-J0xwNm40i0NVEdHYeMRThG7y90X+P/I1ElZGnpQ0AbU=']
});

@ -0,0 +1,12 @@
<script>
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -0,0 +1,6 @@
import { test } from '../../test';
export default test({
mode: ['async'],
csp: { nonce: 'test-nonce' }
});

@ -0,0 +1,12 @@
<script nonce="test-nonce">
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["key",r("bar")]
]) {
h.set(k, v);
}
}
</script>

@ -0,0 +1,4 @@
<script>
import { hydratable } from "svelte";
const foo = await hydratable('key', () => Promise.resolve('bar'));
</script>

@ -21,6 +21,8 @@ interface SSRTest extends BaseTest {
id_prefix?: string;
withoutNormalizeHtml?: boolean;
error?: string;
csp?: { nonce: string } | { hash: true };
script_hashes?: string[];
}
// TODO remove this shim when we can
@ -77,7 +79,8 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
try {
const render_result = render(Component, {
props: config.props || {},
idPrefix: config.id_prefix
idPrefix: config.id_prefix,
csp: config.csp
});
rendered = is_async ? await render_result : render_result;
} catch (error) {
@ -94,7 +97,7 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
assert.fail('Expected an error to be thrown, but rendering succeeded.');
}
const { body, head } = rendered;
const { body, head, hashes } = rendered;
fs.writeFileSync(
`${test_dir}/_output/${is_async ? 'async_rendered.html' : 'rendered.html'}`,
@ -140,6 +143,10 @@ const { test, run } = suite_with_variants<SSRTest, 'sync' | 'async', CompileOpti
}
}
}
if (config.script_hashes !== undefined) {
assert.deepEqual(hashes.script, config.script_hashes);
}
}
);

@ -2553,6 +2553,7 @@ declare module 'svelte/server' {
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
}
]
: [
@ -2561,9 +2562,14 @@ declare module 'svelte/server' {
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
csp?: Csp;
}
]
): RenderOutput;
type Csp = { nonce?: string; hash?: boolean };
type Sha256Source = `sha256-${string}`;
interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;
@ -2571,6 +2577,9 @@ declare module 'svelte/server' {
html: string;
/** HTML that goes somewhere into the `<body>` */
body: string;
hashes: {
script: Sha256Source[];
};
}
type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

Loading…
Cancel
Save