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.
| Package | Affected versions | Patched versions |
|---|---|---|
sveltenpm | >= 5.53.0, < 5.53.5 | 5.53.5 |
Affected products
1Patches
10298e979371bMerge commit from fork
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">--!><img src=x onerror=alert(1)><!--</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">--><img src=x onerror=alert(1)><!--</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"><!--<script>alert(1)</script></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"><!--><!--->--></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- github.com/advisories/GHSA-qgvg-pr8v-6rr3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27902ghsaADVISORY
- github.com/sveltejs/svelte/commit/0298e979371bb583855c9810db79a70a551d22b9ghsax_refsource_MISCWEB
- github.com/sveltejs/svelte/releases/tag/svelte%405.53.5mitrex_refsource_MISC
- github.com/sveltejs/svelte/releases/tag/svelte@5.53.5ghsaWEB
- github.com/sveltejs/svelte/security/advisories/GHSA-qgvg-pr8v-6rr3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.