From c86c4fdca10f9d46a024c1f672e60e1737c1f110 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Fri, 12 Dec 2025 13:59:26 -0700 Subject: [PATCH] feat: Add CSP support for `hydratable` (#17338) --- .changeset/soft-donkeys-serve.md | 5 ++ .../docs/06-runtime/05-hydratable.md | 58 +++++++++++++++++ .../98-reference/.generated/server-errors.md | 6 ++ .../svelte/messages/server-errors/errors.md | 4 ++ packages/svelte/src/internal/server/crypto.js | 41 ++++++++++++ .../svelte/src/internal/server/crypto.test.ts | 15 +++++ packages/svelte/src/internal/server/errors.js | 12 ++++ packages/svelte/src/internal/server/index.js | 9 ++- .../svelte/src/internal/server/renderer.js | 62 +++++++++++++------ .../svelte/src/internal/server/types.d.ts | 7 +++ packages/svelte/src/legacy/legacy-server.js | 13 ++-- packages/svelte/src/server/index.d.ts | 4 +- .../samples/csp-config-error/_config.js | 7 +++ .../csp-config-error/_expected_head.html | 12 ++++ .../samples/csp-config-error/main.svelte | 4 ++ .../samples/csp-hash/_config.js | 7 +++ .../samples/csp-hash/_expected_head.html | 12 ++++ .../samples/csp-hash/main.svelte | 4 ++ .../samples/csp-nonce/_config.js | 6 ++ .../samples/csp-nonce/_expected_head.html | 12 ++++ .../samples/csp-nonce/main.svelte | 4 ++ .../tests/server-side-rendering/test.ts | 11 +++- packages/svelte/types/index.d.ts | 9 +++ 23 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 .changeset/soft-donkeys-serve.md create mode 100644 packages/svelte/src/internal/server/crypto.js create mode 100644 packages/svelte/src/internal/server/crypto.test.ts create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte diff --git a/.changeset/soft-donkeys-serve.md b/.changeset/soft-donkeys-serve.md new file mode 100644 index 0000000000..f0c7236694 --- /dev/null +++ b/.changeset/soft-donkeys-serve.md @@ -0,0 +1,5 @@ +--- +"svelte": minor +--- + +feat: Add `csp` option to `render(...)`, and emit hashes when using `hydratable` diff --git a/documentation/docs/06-runtime/05-hydratable.md b/documentation/docs/06-runtime/05-hydratable.md index a5302f264d..f8d5130581 100644 --- a/documentation/docs/06-runtime/05-hydratable.md +++ b/documentation/docs/06-runtime/05-hydratable.md @@ -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 ``; + `; + + 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 + // 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${body}`; } } 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++}`; diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 05ee34fb17..ea6282c176 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -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: string; @@ -40,6 +44,9 @@ export interface SyncRenderOutput { html: string; /** HTML that goes somewhere into the `` */ body: string; + hashes: { + script: Sha256Source[]; + }; } export type RenderOutput = SyncRenderOutput & PromiseLike; diff --git a/packages/svelte/src/legacy/legacy-server.js b/packages/svelte/src/legacy/legacy-server.js index a50d961751..05b329bea1 100644 --- a/packages/svelte/src/legacy/legacy-server.js +++ b/packages/svelte/src/legacy/legacy-server.js @@ -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; }) => LegacyRenderResult & PromiseLike } */ - const _render = (props, { context } = {}) => { + /** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map; csp?: Csp }) => LegacyRenderResult & PromiseLike } */ + 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} */ ({}), @@ -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); } diff --git a/packages/svelte/src/server/index.d.ts b/packages/svelte/src/server/index.d.ts index d5a3b813e6..f54bd5a5ca 100644 --- a/packages/svelte/src/server/index.d.ts +++ b/packages/svelte/src/server/index.d.ts @@ -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; context?: Map; idPrefix?: string; + csp?: Csp; } ] : [ @@ -24,6 +25,7 @@ export function render< props: Omit; context?: Map; idPrefix?: string; + csp?: Csp; } ] ): RenderOutput; diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js new file mode 100644 index 0000000000..03626fc37b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + csp: { hash: true, nonce: 'test-nonce' }, + error: 'invalid_csp' +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html new file mode 100644 index 0000000000..fb3c95f51f --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/_expected_head.html @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte new file mode 100644 index 0000000000..2c4726edf4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-config-error/main.svelte @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js new file mode 100644 index 0000000000..fe28087d86 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + csp: { hash: true }, + script_hashes: ['sha256-J0xwNm40i0NVEdHYeMRThG7y90X+P/I1ElZGnpQ0AbU='] +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html new file mode 100644 index 0000000000..56319255d6 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/_expected_head.html @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte new file mode 100644 index 0000000000..2c4726edf4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-hash/main.svelte @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js new file mode 100644 index 0000000000..320c0f67f8 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + csp: { nonce: 'test-nonce' } +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html new file mode 100644 index 0000000000..fb3c95f51f --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/_expected_head.html @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte new file mode 100644 index 0000000000..2c4726edf4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/csp-nonce/main.svelte @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index 4b33685608..2bfc84c7a1 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -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; context?: Map; idPrefix?: string; + csp?: Csp; } ] : [ @@ -2561,9 +2562,14 @@ declare module 'svelte/server' { props: Omit; context?: Map; idPrefix?: string; + csp?: Csp; } ] ): RenderOutput; + type Csp = { nonce?: string; hash?: boolean }; + + type Sha256Source = `sha256-${string}`; + interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; @@ -2571,6 +2577,9 @@ declare module 'svelte/server' { html: string; /** HTML that goes somewhere into the `` */ body: string; + hashes: { + script: Sha256Source[]; + }; } type RenderOutput = SyncRenderOutput & PromiseLike;