File: ui/src/utils/meta/Meta.js — actually ui/src/plugins/meta/Meta.js (lines 149-176: getAttr() and getHead())
Sink: injectServerMeta() (same file) → ctx.headTags += getHead(data), interpolated verbatim into the raw HTTP response <head> by the production SSR template (app-vite/templates/entry/ssr-prod-webserver.js + app-vite/lib/plugins/vite.html.js)
Entry point: the public useMeta() composable (ui/src/composables/use-meta/use-meta.js) — the single documented way apps set page title/meta/link/script tags
getHead() is Quasar's SSR-only serializer that turns the meta/link/script/title data collected from every useMeta() call into a literal HTML string, using plain template-literal interpolation with zero HTML-entity escaping and zero attribute-quote escaping:
function getAttr(seed) {
return att => {
const val = seed[att]
return att + (val !== true && val !== void 0 ? `="${val}"` : '')
}
}
function getHead(meta) {
let output = ''
if (meta.title) {
output += `<title>${meta.title}</title>`
}
...
}
Contrast this with the client-side equivalent, apply() (same file, used only in the browser post-hydration), which builds the same tags via document.createElement(...) + tag.setAttribute(att, val) — the browser DOM API automatically escapes attribute values, so the client-side path is not vulnerable. getHead() is a separate, parallel implementation for the SSR case that never received the same protection.
Any string containing </title>, ", or > in a title, meta.*.content, link.*.href, or any other attribute value passed to useMeta() breaks out of its intended HTML context and injects arbitrary markup — including a live <script> tag — directly into the raw HTML response sent to every visitor.
useMeta() pattern — e.g. useMeta(() => ({ title: post.title, meta: { description: { name: 'description', content: post.excerpt } } })) for a blog/CMS/product page.My Post</title><script>alert(document.cookie)</script>.getHead() emits this payload unescaped directly into the <head> of the raw HTML response.<script> executes immediately, before hydration, with full access to document.cookie and the DOM.useMeta() — an extremely common pattern, e.g. blog titles, product names, user display names)useMeta() use case (rendering any dynamic title/description), so it can be triggered even by ordinary content containing &, <, or " without malicious intent, in addition to being trivially exploitable deliberately.ui/src/plugins/meta/Meta.js lines 149-176)function getAttr(seed) {
return att => {
const val = seed[att]
return att + (val !== true && val !== void 0 ? `="${val}"` : '')
}
}
function getHead(meta) {
let output = ''
if (meta.title) {
output += `<title>${meta.title}</title>`
}
;['meta', 'link', 'script'].forEach(type => {
const metaType = meta[type]
for (const att in metaType) {
const attrs = Object.keys(metaType[att])
.filter(item => item !== 'innerHTML')
.map(getAttr(metaType[att]))
output += `<${type} ${attrs.join(' ')} data-qmeta="${att}">`
if (type === 'script') {
output += (metaType[att].innerHTML || '') + '</script>'
}
}
})
return output
}
function escapeHtml(val) {
return String(val)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
}
function getAttr(seed) {
return att => {
const val = seed[att]
return att + (val !== true && val !== void 0 ? `="${escapeHtml(val)}"` : '')
}
}
function getHead(meta) {
let output = ''
if (meta.title) {
output += `<title>${escapeHtml(meta.title)}</title>`
}
// ... rest unchanged, script innerHTML intentionally left raw (JSON-LD use case)
}
Confirmed end-to-end on v2.21.1 via a local Node.js HTTP lab harness that imports the real, unmodified Meta.js source and reproduces the exact useMeta() → injectServerMeta() call sequence a real SSR app performs, reached via actual curl requests (not just isolated function calls):
POST /submit with {"title":"My Post</title><script>alert(document.cookie)</script>","description":"\"><script>alert(document.domain)</script>"}GET /render/1 returned a raw HTTP response whose <head> contained: <title>My Post</title><script>alert(document.cookie)</script></title><meta name="description" content=""><script>alert(document.domain)</script>" data-qmeta="description">grep: 0 occurrences of < (nothing was escaped), 1 occurrence of a literal, unescaped <script>alert(...)</script> tag in the actual HTTP response body.A fix branch (fix/xss-meta-tag-escaping) is ready with the minimal patch above (adds an escapeHtml() helper used in getAttr()/getHead()); re-running the same PoC against the patched code shows the payload fully HTML-entity-encoded (<script>...) with no live <script> tag, while normal titles containing & still render correctly (&).
{
"cwe_ids": [
"CWE-116",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T16:14:22Z",
"nvd_published_at": "2026-10-06T17:17:24Z",
"severity": "CRITICAL"
}