GHSA-g2v6-rqmx-r4w6: @vue/server-renderer: XSS via missing CR in attribute-name blacklist
@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:
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:
// 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:
References
Code Behaviors & Features
Detect and mitigate GHSA-g2v6-rqmx-r4w6 with GitLab Dependency Scanning
Secure your software supply chain by verifying that all open source dependencies used in your projects contain no disclosed vulnerabilities. Dependency Scanning using SBOM also detects vulnerabilities from proprietary advisories that this site does not include. Learn more about Dependency Scanning using SBOM →