Advisory: GHSA-g2v6-rqmx-r4w6 — published 31 August 2026
Package:@vue/server-renderer(npm, ~13.9M downloads/week)
Fixed in:3.5.42(and3.6.0-rc.6) via vuejs/core#15266
Class: CWE-79 (Cross-Site Scripting), CWE-116 (Improper Encoding or Escaping of Output)
Severity: High, 7.2 (CVSS 3.1 —AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N)
Affected:<= 3.5.41(also checked and reproduced against the3.6.0-rc.2tag)
Status: Reported → confirmed same day → fix merged → released in 3.5.42 → advisory published
How I got here
I started by ruling out what doesn’t apply rather than guessing. Vue is a rendering framework — no database access anywhere in its own code, so SQL injection is a non-starter. RCE is a stretch too; nothing in normal operation executes arbitrary system commands. XSS was the only vulnerability class that actually fits a framework whose entire job is turning data into HTML.
From there, client-side rendering only ever affects the same browser already running the app’s own JavaScript — not much of a boundary to cross. @vue/server-renderer is different: its output becomes a real HTTP response body sent to other people’s browsers. That’s the one place in Vue where a rendering bug has genuine cross-user impact, so that’s where I focused.
The asymmetry
ssrRenderDynamicAttr() builds each HTML attribute from an object’s key/value pair — this is what compiled SSR output calls for something like <div v-bind="userObject">. Reading it closely surfaced an asymmetry in how the two sides are treated:
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() — dangerous characters get converted to safe entities, consistently, everywhere in this file. The name only gets tested against a character blacklist in isSSRSafeAttrName() — no escaping, just pass or get discarded entirely:
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)}A blacklist is only as good as the list itself. That’s where the actual gap was.

The gap
That blacklist covers >, /, =, ", ', tab (\t), line feed (\n), form feed (\f), and space. It does not cover \r — carriage return.
That matters because of how browsers actually parse HTML. Per the WHATWG HTML parsing spec, the very first step (“preprocessing the input stream,” §13.2.3.5) converts every \r not followed by \n into a \n before the tokenizer even starts. 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. That’s exactly the gap.
Proving it, one step at a time
Isolate the mechanism. A minimal case first — "foo\rbar" as a key, nothing else unusual — parsed with parse5 (a real, spec-compliant HTML5 parser) to confirm the split happens at all before building anything resembling an exploit.
Build the self-triggering payload. Escalated to x\rautofocus\ronfocus against the real, unmodified, published @vue/server-renderer@3.5.41:
=== How a real HTML5 parser (parse5) interprets this ===Attributes parsed on the <div>: "x" = "" "autofocus" = "" "onfocus" = "alert(document.cookie)"
The single attribute name Vue intended to render safely gets parsed 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 — no click, no hover, no user interaction of any kind.
Confirm it in a real browser. Wrote Vue’s actual, unmodified return value programmatically into a real HTML file — no hand-typed HTML — and opened it. The payload fired on load, with no interaction:

DevTools confirms the browser genuinely parsed three separate attributes out of what Vue rendered as one name — x, src, and a live onerror handler:

Ruling out client-side rendering
I also checked whether this affects client-side (non-SSR) Vue: it doesn’t. Client-side Vue sets dynamic attributes via the DOM’s setAttribute() API, which validates the name against the HTML QName grammar and throws a DOMException for characters like " — a fundamentally different code path with its own validation. This is specifically and only an 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 compiled equivalent. Binding untrusted values into attributes is the standard, everyday Vue pattern, already safely handled by escapeHtml(). Binding untrusted keys is less universal, but it’s a real, documented, supported Vue feature — and exactly the scenario isSSRSafeAttrName() exists to defend, which means it was already inside the framework’s own threat model for this file, just not fully closed.
Realistic shapes this takes: a CMS or form-builder where field/attribute names are configurable by a less-trusted role and rendered via SSR to other users; a component that spreads a validated-elsewhere config object onto a root element; any dynamic-attributes helper fed by a database record, an API response, or a query string parsed into an object.
Reporting it
Vue’s disclosure policy explicitly rules out “XSS via template expressions” as in scope — I made sure to address that up front in the report itself, since this isn’t that. This isn’t about template source at all; it’s about ordinary, documented runtime data binding, where the guard that’s supposed to make untrusted data safe for that documented feature had a gap in it.
The response
Maintainer edison1105 confirmed the issue the same day:
“Exploitation requires an SSR application to pass an object with attacker-controlled property names through object-form v-bind and render the result to other users… Although this can result in XSS when those prerequisites are met, the vulnerable data flow is narrow, uncommon, and application-specific. We therefore assess the practical severity as moderate and do not believe an embargoed private-fix process is necessary. A public patch is proportionate to the actual exposure…”
No dispute on the framing, no pushback on scope — just a clear, transparent explanation of why a public (non-embargoed) fix was the right call, which is worth more than a severity number with no context behind it.
Reviewing the fix
One character added, closing the exact gap:
const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u000d\u0020]/That’s now the complete WHATWG “ASCII whitespace” set — tab, line feed, form feed, carriage return, space — checked as a full set instead of enumerated one character short. The PR added a regression test using the exact payload from the report. I independently re-verified the patched regex against the original payload myself before commenting — confirmed rejected, confirmed normal attribute names unaffected.
Disclosure timeline
- Day 1 — Reported, with the “this isn’t template-expression XSS” scope distinction addressed up front, full PoC attached.
- Day 2 — edison1105 confirmed the issue, explained the public-patch reasoning, opened #15266. Fix merged to
mainthe same day. I reviewed and independently verified the patch before it merged. - Day 18 —
3.5.42published to npm carrying the fix;3.6.0-rc.6followed the next day, so theminorbranch was covered before any stable 3.6 shipped. I re-ran the original payload against the published3.5.42: the attribute is now dropped entirely, with aSkipped rendering unsafe attribute namewarning. Fix confirmed effective against the real package, not just present in source. - Day 22 — advisory published as GHSA-g2v6-rqmx-r4w6, credited, with the patched version recorded as
3.5.42. The maintainer opted for a GitHub advisory without requesting a CVE. Worth knowing how that propagates: a repository advisory only reachesnpm auditand Dependabot once GitHub’s own review promotes it into the global advisory database, and at the time of writing it hasn’t been promoted yet — so upgrading to3.5.42is still a deliberate move rather than something a scanner will nag you about. This write-up was explicitly pre-approved once the advisory went public.
Takeaways
- A blacklist is only as complete as your cross-reference. Vue’s own list looked reasonable in isolation. The gap only showed up by checking it against the actual browser parsing spec, not just trusting the enumeration.
- Escaping and blacklisting are not the same guarantee. Values were escaped and safe. Names were only gated — and a gate is exactly as strong as its weakest excluded character.
- Prove the mechanism before building the exploit. Isolating
"foo\rbar"first, before the self-triggering payload, before a real browser confirmation, meant every escalation step had something solid under it. - State what you didn’t prove, not just what you did. Ruling out client-side rendering — and saying so plainly — made the report easier to trust, not harder.
References
- GHSA-g2v6-rqmx-r4w6 — this advisory
- vuejs/core#15266 — the fix
- WHATWG HTML Standard §13.2.3.5 — preprocessing the input stream
- CWE-79 — Cross-Site Scripting
- CWE-116 — Improper Encoding or Escaping of Output
Reported by Youssef Aboukir (onevilx). Thanks to edison1105 for the fast, transparent turnaround.
onevilx