GHSA-PQ96-JPMF-W254
Vulnerability from github – Published: 2026-10-07 16:14 – Updated: 2026-10-07 16:14Vulnerability Details
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
Root Cause
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.
Attack Scenario
- A Quasar SSR application renders dynamic page metadata via the standard, documented
useMeta()pattern — e.g.useMeta(() => ({ title: post.title, meta: { description: { name: 'description', content: post.excerpt } } }))for a blog/CMS/product page. - An attacker who can influence that underlying text (submit a blog post/comment, set their own profile display name, control a field in an integrated CMS/API) sets it to
My Post</title><script>alert(document.cookie)</script>. - On every SSR render of that page — for every visitor —
getHead()emits this payload unescaped directly into the<head>of the raw HTML response. - The victim's browser parses the HTML top-to-bottom; the injected
<script>executes immediately, before hydration, with full access todocument.cookieand the DOM.
Impact
- Type: CWE-79 Cross-Site Scripting
- Auth required: No (attacker only needs to influence any text that reaches
useMeta()— an extremely common pattern, e.g. blog titles, product names, user display names) - Consequence: Full client-side script execution in the victim site's origin — session/cookie theft, credential phishing overlays, account takeover, defacement. Unlike a typical XSS bug requiring a specific unusual injection point, this affects the single most common
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.
Vulnerable Code (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
}
Recommended Fix
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)
}
Verification
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 /submitwith{"title":"My Post</title><script>alert(document.cookie)</script>","description":"\"><script>alert(document.domain)</script>"}GET /render/1returned 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">- Verified via
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 (&).
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "quasar"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.22.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-106102"
],
"database_specific": {
"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"
},
"details": "## Vulnerability Details\n\n**File**: `ui/src/utils/meta/Meta.js` \u2014 actually `ui/src/plugins/meta/Meta.js` (lines 149-176: `getAttr()` and `getHead()`)\n**Sink**: `injectServerMeta()` (same file) \u2192 `ctx.headTags += getHead(data)`, interpolated verbatim into the raw HTTP response `\u003chead\u003e` by the production SSR template (`app-vite/templates/entry/ssr-prod-webserver.js` + `app-vite/lib/plugins/vite.html.js`)\n**Entry point**: the public `useMeta()` composable (`ui/src/composables/use-meta/use-meta.js`) \u2014 the single documented way apps set page title/meta/link/script tags\n\n### Root Cause\n`getHead()` is Quasar\u0027s 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**:\n\n```js\nfunction getAttr(seed) {\n return att =\u003e {\n const val = seed[att]\n return att + (val !== true \u0026\u0026 val !== void 0 ? `=\"${val}\"` : \u0027\u0027)\n }\n}\n\nfunction getHead(meta) {\n let output = \u0027\u0027\n if (meta.title) {\n output += `\u003ctitle\u003e${meta.title}\u003c/title\u003e`\n }\n ...\n}\n```\n\nContrast 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)` \u2014 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.\n\nAny string containing `\u003c/title\u003e`, `\"`, or `\u003e` 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 \u2014 including a live `\u003cscript\u003e` tag \u2014 directly into the raw HTML response sent to every visitor.\n\n### Attack Scenario\n1. A Quasar SSR application renders dynamic page metadata via the standard, documented `useMeta()` pattern \u2014 e.g. `useMeta(() =\u003e ({ title: post.title, meta: { description: { name: \u0027description\u0027, content: post.excerpt } } }))` for a blog/CMS/product page.\n2. An attacker who can influence that underlying text (submit a blog post/comment, set their own profile display name, control a field in an integrated CMS/API) sets it to `My Post\u003c/title\u003e\u003cscript\u003ealert(document.cookie)\u003c/script\u003e`.\n3. On every SSR render of that page \u2014 for every visitor \u2014 `getHead()` emits this payload unescaped directly into the `\u003chead\u003e` of the raw HTML response.\n4. The victim\u0027s browser parses the HTML top-to-bottom; the injected `\u003cscript\u003e` executes immediately, before hydration, with full access to `document.cookie` and the DOM.\n\n### Impact\n- **Type**: CWE-79 Cross-Site Scripting\n- **Auth required**: No (attacker only needs to influence any text that reaches `useMeta()` \u2014 an extremely common pattern, e.g. blog titles, product names, user display names)\n- **Consequence**: Full client-side script execution in the victim site\u0027s origin \u2014 session/cookie theft, credential phishing overlays, account takeover, defacement. Unlike a typical XSS bug requiring a specific unusual injection point, this affects the single most common `useMeta()` use case (rendering any dynamic title/description), so it can be triggered even by ordinary content containing `\u0026`, `\u003c`, or `\"` without malicious intent, in addition to being trivially exploitable deliberately.\n\n### Vulnerable Code (`ui/src/plugins/meta/Meta.js` lines 149-176)\n```js\nfunction getAttr(seed) {\n return att =\u003e {\n const val = seed[att]\n return att + (val !== true \u0026\u0026 val !== void 0 ? `=\"${val}\"` : \u0027\u0027)\n }\n}\n\nfunction getHead(meta) {\n let output = \u0027\u0027\n if (meta.title) {\n output += `\u003ctitle\u003e${meta.title}\u003c/title\u003e`\n }\n ;[\u0027meta\u0027, \u0027link\u0027, \u0027script\u0027].forEach(type =\u003e {\n const metaType = meta[type]\n for (const att in metaType) {\n const attrs = Object.keys(metaType[att])\n .filter(item =\u003e item !== \u0027innerHTML\u0027)\n .map(getAttr(metaType[att]))\n output += `\u003c${type} ${attrs.join(\u0027 \u0027)} data-qmeta=\"${att}\"\u003e`\n if (type === \u0027script\u0027) {\n output += (metaType[att].innerHTML || \u0027\u0027) + \u0027\u003c/script\u003e\u0027\n }\n }\n })\n return output\n}\n```\n\n### Recommended Fix\n```js\nfunction escapeHtml(val) {\n return String(val)\n .replaceAll(\u0027\u0026\u0027, \u0027\u0026amp;\u0027)\n .replaceAll(\u0027\u003c\u0027, \u0027\u0026lt;\u0027)\n .replaceAll(\u0027\u003e\u0027, \u0027\u0026gt;\u0027)\n .replaceAll(\u0027\"\u0027, \u0027\u0026quot;\u0027)\n}\n\nfunction getAttr(seed) {\n return att =\u003e {\n const val = seed[att]\n return att + (val !== true \u0026\u0026 val !== void 0 ? `=\"${escapeHtml(val)}\"` : \u0027\u0027)\n }\n}\n\nfunction getHead(meta) {\n let output = \u0027\u0027\n if (meta.title) {\n output += `\u003ctitle\u003e${escapeHtml(meta.title)}\u003c/title\u003e`\n }\n // ... rest unchanged, script innerHTML intentionally left raw (JSON-LD use case)\n}\n```\n\n### Verification\nConfirmed 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()` \u2192 `injectServerMeta()` call sequence a real SSR app performs, reached via actual `curl` requests (not just isolated function calls):\n\n1. `POST /submit` with `{\"title\":\"My Post\u003c/title\u003e\u003cscript\u003ealert(document.cookie)\u003c/script\u003e\",\"description\":\"\\\"\u003e\u003cscript\u003ealert(document.domain)\u003c/script\u003e\"}`\n2. `GET /render/1` returned a raw HTTP response whose `\u003chead\u003e` contained: `\u003ctitle\u003eMy Post\u003c/title\u003e\u003cscript\u003ealert(document.cookie)\u003c/script\u003e\u003c/title\u003e\u003cmeta name=\"description\" content=\"\"\u003e\u003cscript\u003ealert(document.domain)\u003c/script\u003e\" data-qmeta=\"description\"\u003e`\n3. Verified via `grep`: 0 occurrences of `\u0026lt;` (nothing was escaped), 1 occurrence of a literal, unescaped `\u003cscript\u003ealert(...)\u003c/script\u003e` tag in the actual HTTP response body.\n\nA 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 (`\u0026lt;script\u0026gt;...`) with no live `\u003cscript\u003e` tag, while normal titles containing `\u0026` still render correctly (`\u0026amp;`).",
"id": "GHSA-pq96-jpmf-w254",
"modified": "2026-10-07T16:14:22Z",
"published": "2026-10-07T16:14:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/quasarframework/quasar/security/advisories/GHSA-pq96-jpmf-w254"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-106102"
},
{
"type": "WEB",
"url": "https://github.com/quasarframework/quasar/commit/11505afe5b5218f2c468f130181815b898fd1e40"
},
{
"type": "PACKAGE",
"url": "https://github.com/quasarframework/quasar"
},
{
"type": "WEB",
"url": "https://github.com/quasarframework/quasar/releases/tag/quasar-v2.22.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Quasar Framework: Stored/Reflected XSS via unescaped SSR meta tag rendering in getHead()"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.