diff --git a/.changeset/soft-donkeys-serve.md b/.changeset/soft-donkeys-serve.md new file mode 100644 index 000000000000..f0c723669445 --- /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 a5302f264dd1..f8d513058161 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 05ee34fb172c..ea6282c176ac 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 a50d961751d8..05b329bea12e 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 d5a3b813e6cb..f54bd5a5cadc 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 000000000000..03626fc37bd6 --- /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 000000000000..fb3c95f51fe3 --- /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 000000000000..2c4726edf478 --- /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 000000000000..fe28087d8694 --- /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 000000000000..56319255d6b6 --- /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 000000000000..2c4726edf478 --- /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 000000000000..320c0f67f82e --- /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 000000000000..fb3c95f51fe3 --- /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 000000000000..2c4726edf478 --- /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 4b3368560870..2bfc84c7a1c7 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;