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}
+