From 0298e979371bb583855c9810db79a70a551d22b9 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Wed, 25 Feb 2026 09:14:31 -0700 Subject: [PATCH] Merge commit from fork * fix: sanitize `transformError` values prior to embedding in HTML comments * did a little dumb --- .changeset/calm-shrimps-live.md | 5 ++ .../svelte/src/internal/server/renderer.js | 23 ++++-- .../src/internal/server/renderer.test.ts | 79 +++++++++++++++++++ .../_config.js | 8 ++ .../_expected.html | 1 + .../main.svelte | 15 ++++ .../_config.js | 8 ++ .../_expected.html | 1 + .../main.svelte | 15 ++++ .../_config.js | 8 ++ .../_expected.html | 1 + .../main.svelte | 15 ++++ .../_config.js | 8 ++ .../_expected.html | 1 + .../main.svelte | 15 ++++ 15 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-shrimps-live.md create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/main.svelte create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_config.js create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_expected.html create mode 100644 packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/main.svelte diff --git a/.changeset/calm-shrimps-live.md b/.changeset/calm-shrimps-live.md new file mode 100644 index 0000000000..1400b08e42 --- /dev/null +++ b/.changeset/calm-shrimps-live.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: sanitize `transformError` values prior to embedding in HTML comments diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index c6966090dc..da65811eca 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -294,13 +294,13 @@ export class Renderer { child.promise = /** @type {Promise} */ (result).then((transformed) => { set_ssr_context(parent_context); - child.#out.push(``); + child.#out.push(Renderer.#serialize_failed_boundary(transformed)); failed_snippet(child, transformed, noop); child.#out.push(BLOCK_CLOSE); }); child.promise.catch(noop); } else { - child.#out.push(``); + child.#out.push(Renderer.#serialize_failed_boundary(result)); failed_snippet(child, result, noop); child.#out.push(BLOCK_CLOSE); } @@ -482,6 +482,21 @@ export class Renderer { return this.#out.length; } + /** + * Creates the hydration comment that marks the start of a failed boundary. + * The error is JSON-serialized and embedded inside an HTML comment for the client + * to parse during hydration. The JSON is escaped to prevent `-->` or ``; + } + /** * Only available on the server and when compiling with the `server` option. * 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. @@ -701,9 +716,7 @@ export class Renderer { // Render the failed snippet instead of the partial children content const failed_renderer = new Renderer(item.global, item); failed_renderer.type = item.type; - failed_renderer.#out.push( - `` - ); + failed_renderer.#out.push(Renderer.#serialize_failed_boundary(transformed)); failed(failed_renderer, transformed, noop); failed_renderer.#out.push(BLOCK_CLOSE); await failed_renderer.#collect_content_async(content); diff --git a/packages/svelte/src/internal/server/renderer.test.ts b/packages/svelte/src/internal/server/renderer.test.ts index bbbe3f745d..3525e0b466 100644 --- a/packages/svelte/src/internal/server/renderer.test.ts +++ b/packages/svelte/src/internal/server/renderer.test.ts @@ -225,6 +225,85 @@ test('select merges scoped css hash with static class', () => { ); }); +describe('boundary hydration comment escaping', () => { + const failed_snippet = (renderer: Renderer, error: unknown) => { + renderer.push(`

${(error as { message: string }).message}

`); + }; + + const transform = (error: unknown) => ({ message: (error as Error).message }); + + const payloads = [ + { name: 'escapes -->', input: '-->', input: '', expected: '{"message":"\\u003c!--\\u003e"}' }, + { name: 'escapes ', input: '', expected: '{"message":"\\u003c!---\\u003e"}' }, + { name: 'escapes multiple -->', input: '-->one-->two-->', expected: '{"message":"--\\u003eone--\\u003etwo--\\u003e"}' }, + { name: 'escapes --->', input: '--->', expected: '{"message":"---\\u003e"}' }, + { name: 'no double-encoding', input: '--\\u003e', expected: '{"message":"--\\\\u003e"}' }, + { name: 'the terrifying special pointy boy', input: '--!>ooh, what an exotic closing comment tag', expected: '{"message":"--!\\u003eooh, what an exotic closing comment tag"}' } + ]; + + type RenderFn = (input: string) => Promise | string; + + const paths: Array<{ path: string; async: boolean; render: RenderFn }> = [ + { + path: 'sync children, sync transformError', + async: false, + render: (input) => { + const component = (renderer: Renderer) => { + renderer.boundary({ failed: failed_snippet }, () => { throw new Error(input); }); + }; + return Renderer.render(component as unknown as Component, { transformError: transform } as any).body; + } + }, + { + path: 'sync children, async transformError', + async: true, + render: async (input) => { + const component = (renderer: Renderer) => { + renderer.boundary({ failed: failed_snippet }, () => { throw new Error(input); }); + }; + return (await Renderer.render(component as unknown as Component, { + transformError: (error: unknown) => Promise.resolve(transform(error)) + } as any)).body; + } + }, + { + path: 'async children throw', + async: true, + render: async (input) => { + const component = (renderer: Renderer) => { + renderer.boundary({ failed: failed_snippet }, async () => { + await Promise.resolve(); + throw new Error(input); + }); + }; + return (await Renderer.render(component as unknown as Component, { + transformError: transform + } as any)).body; + } + } + ]; + + describe.each(paths)('$path', ({ async: needs_async, render }) => { + if (needs_async) { + beforeAll(() => enable_async_mode_flag()); + afterAll(() => disable_async_mode_flag()); + } + + test.each(payloads)('$name', async ({ input, expected }) => { + const body = await render(input); + + // Extract the content between + // If escaping is broken, an unescaped --> in the JSON will truncate + // the match and the content won't equal the expected escaped JSON. + const match = body.match(//); + expect(match, 'expected a hydration comment in output').toBeTruthy(); + expect(match![1]).toBe(expected); + }); + }); +}); + describe('async', () => { beforeAll(() => { enable_async_mode_flag(); diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_config.js b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_config.js new file mode 100644 index 0000000000..2e3ffc6ab3 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + props: { + query: '--!><img src=x onerror=alert(1)><!--

diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/main.svelte b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/main.svelte new file mode 100644 index 0000000000..dd2955ecc4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/main.svelte @@ -0,0 +1,15 @@ + + + +

{search(query)}

+ + {#snippet failed(error)} +

{error.message}

+ {/snippet} +
diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_config.js b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_config.js new file mode 100644 index 0000000000..1644604a7b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + props: { + query: '--><img src=x onerror=alert(1)><!--

diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/main.svelte b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/main.svelte new file mode 100644 index 0000000000..dd2955ecc4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/main.svelte @@ -0,0 +1,15 @@ + + + +

{search(query)}

+ + {#snippet failed(error)} +

{error.message}

+ {/snippet} +
diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_config.js b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_config.js new file mode 100644 index 0000000000..a4f74e8b21 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + props: { + query: '-->' + }, + transformError: (error) => ({ message: /** @type {Error} */ (error).message }) +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_expected.html b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_expected.html new file mode 100644 index 0000000000..7d338ce71b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_expected.html @@ -0,0 +1 @@ +

<!--><!--->-->

diff --git a/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/main.svelte b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/main.svelte new file mode 100644 index 0000000000..dd2955ecc4 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/main.svelte @@ -0,0 +1,15 @@ + + + +

{search(query)}

+ + {#snippet failed(error)} +

{error.message}

+ {/snippet} +