Merge commit from fork

* fix: sanitize `transformError` values prior to embedding in HTML comments

* did a little dumb
pull/17802/head
Elliott Johnson 7 months ago committed by GitHub
parent 96fd3ce763
commit 0298e97937
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: sanitize `transformError` values prior to embedding in HTML comments

@ -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);

@ -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();

@ -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 })
});

@ -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>

@ -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 })
});

@ -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>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
props: {
query: '<!--<script>alert(1)</script>'
},
transformError: (error) => ({ message: /** @type {Error} */ (error).message })
});

@ -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>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
props: {
query: '<!--><!--->-->'
},
transformError: (error) => ({ message: /** @type {Error} */ (error).message })
});

@ -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>
Loading…
Cancel
Save