Summary
Last year, I reported GHSA-5c3j-59mh-x5gj, an SSR XSS in Vue.js Core. The fix was merged publicly on January 16, 2026 and shipped in Vue 3.5.27 on January 19, with a reference to the private GHSA. The advisory remained private as of publication, which tracks since Vue has never published an advisory. I did not push for a CVE beyond my intial ask, because the vulnerability was unlikely to see significant real-world exploitation, though I did find a couple examples of downstream exposure.
Below is my publish-ready report with technical details removed.

Technical Analysis
Vue.js was initially designed primarily as a client-side front-end JavaScript framework. As modern JavaScript frameworks evolved, they added server-side rendering (SSR) capabilities such as streaming, component-level caching, and client-side hydration. These features enable dynamic content to be rendered on the server, but they can also introduce SSR-based XSS vulnerabilities when client-supplied values are reflected into server-generated HTML without proper contextual escaping or sanitization.
In frameworks like Vue, it’s difficult to assess whether XSS mitigation is the responsibility of either the framework or the application developers. When using LLMs for vulnerability research, models typically surface every potential sink as a vulnerability, when, in reality, the documented API contract delegates security to callers. This is where API contracts carry a lot of weight, and why maintainers are screaming for help as they’re buried in AI slop reports for intentional design choices.
I reported GHSA-5c3j-59mh-x5gj in the SSR class attribute rendering method in Vue.js Core. The shape is simple. Below, the class attribute is escaped with class="${ssrRenderClass(value)}", while the className attribute was serialized as class="${String(value)}" without HTML escaping. I suspect this was an oversight when adding support for React JSX/TSX naming.
export function ssrRenderAttrs(
props: Record<string, unknown>,
tag?: string,
): string {
let ret = ''
for (const key in props) {
if (
shouldIgnoreProp(key) ||
isOn(key) ||
(tag === 'textarea' && key === 'value')
) {
continue
}
const value = props[key]
if (key === 'class') {
ret += ` class="${ssrRenderClass(value)}"`
} else if (key === 'style') {
ret += ` style="${ssrRenderStyle(value)}"`
} else if (key === 'className') {
ret += ` class="${String(value)}"`
} else {
ret += ssrRenderDynamicAttr(key, value, tag)
}
}
return ret
}
First, the elephant in the room. Vue’s documented render-function and JSX APIs use class, not React-style className. Dynamic class values are normal, documented Vue usage. Most projects are probably using hardcoded properties in a template defined like so.
<template>
<div class="profile-card theme-light">
hello
</div>
</template>
The class is then populated by the SSR engine. There is no realistic entrypoint for injection.
import { createSSRApp, h } from 'vue'
const app = createSSRApp({
render() {
return h(
'div',
{ class: 'profile-card theme-light' },
'hello'
)
}
})
However, the runtime also accepts className as a compatibility alias, and Vue 3.5 added explicit SSR handling for it. This matters in shared component libraries, JSX-heavy projects, and codebases where developers carry React conventions into Vue. Once Vue Core maps that property to an HTML class attribute, it must preserve the same escaping guarantees as the documented class path.
const app = createSSRApp({
render: () =>
h('div', { className: `profile-card theme-${user.theme}` }, 'hello')
})
The example below demonstrates the shape of a weak application using React syntax, where the payload is a value influenced by a client.
import { createSSRApp, h } from 'vue'
import { renderToString } from '@vue/server-renderer'
import http from 'node:http'
const payload =
'"><script>alert("XSS via className");</script><div class="'
const app = createSSRApp({
render: () => h('div', { className: payload }, 'hello')
})
const body = await renderToString(app)
const page = `<!doctype html>
<html>
<body>
${body}
</body>
</html>`
http
.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(page)
})
.listen(3000, () => {
console.log('Serving PoC at http://localhost:3000')
})
The big question then becomes: Is this value contextually escaped by the framework, or does the API contract delegate that responsibility to the caller? This is what I looked to test with LLMs. Luckily, Vue.js very clearly documents their API contract in their best practices!


Vue.js makes it clear that developers should be able to trust that most properties are properly escaped. But it’s easy for open source projects to miss filters as features sprawl into unmaintainability without appropriate adversarial test coverage. Many security engineers will tell you to always contextually escape everything, regardless of what an API contract promises. I think that this is a reasonable approach for rapidly developing critical applications where security trumps performance, but it’s not generally sound.
When assessing projects for SSR vulnerabilities using LLMs, an API contract must be defined and provided. Even weaker local, quantized models will recognize these weaknesses when the appropriate components are isolated alongside proper documentation. This vulnerability in Vue.js Core is a prime example of how even battle-tested frameworks have holes around the edges. While this particular vulnerability is narrowly exploitable, it’s the edge cases in popular frameworks that surprise developers.
Patches
The issue was patched in Vue.js Core v3.5.27. The affected range appears to be Vue 3.5.x through 3.5.26.
The fix was simple. The className block is removed and appended to the class handler.
export function ssrRenderAttrs(
props: Record<string, unknown>,
tag?: string,
): string {
let ret = ''
for (const key in props) {
if (
shouldIgnoreProp(key) ||
isOn(key) ||
(tag === 'textarea' && key === 'value')
) {
continue
}
const value = props[key]
if (key === 'class' || key === 'className') {
ret += ` class="${ssrRenderClass(value)}"`
} else if (key === 'style') {
ret += ` style="${ssrRenderStyle(value)}"`
} else {
ret += ssrRenderDynamicAttr(key, value, tag)
}
}
return ret
}
Timeline
| Date | Event |
|---|---|
| 2025-12-20 | Initial Report via GitHub Private Vulnerability Report |
| 2026-01-16 | Maintainer accepts report |
| 2026-01-19 | Patch released publicly with bug details and reference to GHSA |
| 2026-01-19 | I reviewed the patch and prepared the report for publication |
| 2026-02-19 | I followed up to request publication with no response |
| 2026-06-23 | Published to websmite.com |