VYPR
Moderate severityNVD Advisory· Published Feb 26, 2026· Updated Feb 26, 2026

Svelte Vulnerable to XSS via HTML Comment Injection in SSR Error Boundary Hydration Markers

CVE-2026-27902

Description

Svelte performance oriented web framework. Prior to version 5.53.5, errors from transformError were not correctly escaped prior to being embedded in the HTML output, causing potential HTML injection and XSS if attacker-controlled content is returned from transformError. Version 5.53.5 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
sveltenpm
>= 5.53.0, < 5.53.55.53.5

Affected products

1

Patches

1
0298e979371b

Merge commit from fork

https://github.com/sveltejs/svelteElliott JohnsonFeb 25, 2026via ghsa
15 files changed · +198 5
  • .changeset/calm-shrimps-live.md+5 0 added
    @@ -0,0 +1,5 @@
    +---
    +'svelte': patch
    +---
    +
    +fix: sanitize `transformError` values prior to embedding in HTML comments
    
  • packages/svelte/src/internal/server/renderer.js+18 5 modified
    @@ -294,13 +294,13 @@ export class Renderer {
     
     				child.promise = /** @type {Promise<unknown>} */ (result).then((transformed) => {
     					set_ssr_context(parent_context);
    -					child.#out.push(`<!--${HYDRATION_START_FAILED}${JSON.stringify(transformed)}-->`);
    +					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(`<!--${HYDRATION_START_FAILED}${JSON.stringify(result)}-->`);
    +				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 `<!--` sequences
    +	 * from breaking out of the comment (XSS). Uses unicode escapes which `JSON.parse()`
    +	 * handles transparently.
    +	 * @param {unknown} error
    +	 * @returns {string}
    +	 */
    +	static #serialize_failed_boundary(error) {
    +		var json = JSON.stringify(error);
    +		var escaped = json.replace(/>/g, '\\u003e').replace(/</g, '\\u003c');
    +		return `<!--${HYDRATION_START_FAILED}${escaped}-->`;
    +	}
    +
     	/**
     	 * 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(
    -							`<!--${HYDRATION_START_FAILED}${JSON.stringify(transformed)}-->`
    -						);
    +						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);
    
  • packages/svelte/src/internal/server/renderer.test.ts+79 0 modified
    @@ -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(`<p>${(error as { message: string }).message}</p>`);
    +	};
    +
    +	const transform = (error: unknown) => ({ message: (error as Error).message });
    +
    +	const payloads = [
    +		{ name: 'escapes -->', input: '--><img src=x onerror=alert(1)><!--', expected: '{"message":"--\\u003e\\u003cimg src=x onerror=alert(1)\\u003e\\u003c!--"}' },
    +		{ name: 'escapes <!--', input: '<!--<script>alert(1)</script>', expected: '{"message":"\\u003c!--\\u003cscript\\u003ealert(1)\\u003c/script\\u003e"}' },
    +		{ name: 'escapes <!-->',  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> | 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 <!--[? and the first -->
    +			// 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();
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_config.js+8 0 added
    @@ -0,0 +1,8 @@
    +import { test } from '../../test';
    +
    +export default test({
    +	props: {
    +		query: '--!><img src=x onerror=alert(1)><!--'
    +	},
    +	transformError: (error) => ({ message: /** @type {Error} */ (error).message })
    +});
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/_expected.html+1 0 added
    @@ -0,0 +1 @@
    +<p class="error">--!>&lt;img src=x onerror=alert(1)>&lt;!--</p>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-close-bang-escape/main.svelte+15 0 added
    @@ -0,0 +1,15 @@
    +<script>
    +	let { query } = $props();
    +
    +	function search(q) {
    +		throw new Error(q);
    +	}
    +</script>
    +
    +<svelte:boundary>
    +	<p>{search(query)}</p>
    +
    +	{#snippet failed(error)}
    +		<p class="error">{error.message}</p>
    +	{/snippet}
    +</svelte:boundary>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_config.js+8 0 added
    @@ -0,0 +1,8 @@
    +import { test } from '../../test';
    +
    +export default test({
    +	props: {
    +		query: '--><img src=x onerror=alert(1)><!--'
    +	},
    +	transformError: (error) => ({ message: /** @type {Error} */ (error).message })
    +});
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/_expected.html+1 0 added
    @@ -0,0 +1 @@
    +<p class="error">-->&lt;img src=x onerror=alert(1)>&lt;!--</p>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-escape/main.svelte+15 0 added
    @@ -0,0 +1,15 @@
    +<script>
    +	let { query } = $props();
    +
    +	function search(q) {
    +		throw new Error(q);
    +	}
    +</script>
    +
    +<svelte:boundary>
    +	<p>{search(query)}</p>
    +
    +	{#snippet failed(error)}
    +		<p class="error">{error.message}</p>
    +	{/snippet}
    +</svelte:boundary>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_config.js+8 0 added
    @@ -0,0 +1,8 @@
    +import { test } from '../../test';
    +
    +export default test({
    +	props: {
    +		query: '<!--<script>alert(1)</script>'
    +	},
    +	transformError: (error) => ({ message: /** @type {Error} */ (error).message })
    +});
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/_expected.html+1 0 added
    @@ -0,0 +1 @@
    +<p class="error">&lt;!--&lt;script&gt;alert(1)&lt;/script&gt;</p>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-open-escape/main.svelte+15 0 added
    @@ -0,0 +1,15 @@
    +<script>
    +	let { query } = $props();
    +
    +	function search(q) {
    +		throw new Error(q);
    +	}
    +</script>
    +
    +<svelte:boundary>
    +	<p>{search(query)}</p>
    +
    +	{#snippet failed(error)}
    +		<p class="error">{error.message}</p>
    +	{/snippet}
    +</svelte:boundary>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_config.js+8 0 added
    @@ -0,0 +1,8 @@
    +import { test } from '../../test';
    +
    +export default test({
    +	props: {
    +		query: '<!--><!--->-->'
    +	},
    +	transformError: (error) => ({ message: /** @type {Error} */ (error).message })
    +});
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/_expected.html+1 0 added
    @@ -0,0 +1 @@
    +<p class="error">&lt;!--&gt;&lt;!---&gt;--&gt;</p>
    
  • packages/svelte/tests/server-side-rendering/samples/boundary-error-html-comment-overlap-escape/main.svelte+15 0 added
    @@ -0,0 +1,15 @@
    +<script>
    +	let { query } = $props();
    +
    +	function search(q) {
    +		throw new Error(q);
    +	}
    +</script>
    +
    +<svelte:boundary>
    +	<p>{search(query)}</p>
    +
    +	{#snippet failed(error)}
    +		<p class="error">{error.message}</p>
    +	{/snippet}
    +</svelte:boundary>
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.