{"id":"CVE-2026-31860","aliases":["CLSA-2026-1788988912","CLSA-2026-1792161471","CLSA-2026-1792161537","CLSA-2026-1792161579","CLSA-2026-1792161644","CLSA-2026-1792162014","EUVD-2026-11639","GHSA-g5xx-pwrp-g3fv"],"status":"published","title":"Unhead has a XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check","description":"Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered \u003chead\u003e tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.","severity":{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00292,"percentile":0.22105,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-79"],"published":"2026-03-12T14:19:15Z","modified":"2026-10-28T02:30:24Z","fixes":[{"ecosystem":"npm","package":"unhead","fixed_in":["2.1.11"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"unhead","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"2.1.10"},{"fixed":"2.1.11"}]}]},{"source":"osv","ecosystem":"TuxCare:npm","package":"unhead","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"TuxCare:npm","package":"@unhead/dom","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"TuxCare:npm","package":"@unhead/shared","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"TuxCare:npm","package":"@unhead/ssr","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"TuxCare:npm","package":"@unhead/vue","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"TuxCare:npm","package":"@unhead/schema","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.11.20-tuxcare.0"},{"fixed":"1.11.20-tuxcare.1"}]}],"versions":["1.11.20"],"bundled":2},{"source":"osv","ecosystem":"npm","package":"unhead","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.1.11"}]}]}],"products":[{"vendor":"unjs","product":"unhead"}],"references":[{"url":"https://github.com/unjs/unhead/security/advisories/GHSA-g5xx-pwrp-g3fv","tags":["x_refsource_CONFIRM","exploit","Exploit","Mitigation","Vendor Advisory","advisory","web"]},{"url":"https://github.com/unjs/unhead/commit/9ecc4f9568b0e23938f36d4b23fcfa4a18a89045","tags":["web"]},{"url":"https://github.com/unjs/unhead/releases/tag/v2.1.11","tags":["web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-31860","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-g5xx-pwrp-g3fv"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/31xxx/CVE-2026-31860.json","tags":["advisory"]},{"url":"https://github.com/unjs/unhead","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-31860","title":"Unhead has a XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check","description":"Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered \u003chead\u003e tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.","status":"published","published":"2026-03-12T17:18:20.452Z","modified":"2026-03-13T16:26:51.67Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium","assessor":"cna"}],"cwes":["CWE-79"],"updated_at":"2026-11-02T14:19:51.277563Z"}],"euvd":[{"id":"EUVD-2026-11639","description":"Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered \u003chead\u003e tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.","status":"published","published":"2026-03-12T17:18:20Z","modified":"2026-03-13T16:26:51Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium"}],"updated_at":"2026-11-02T19:25:35.010093Z"}],"ghsa":[{"id":"GHSA-g5xx-pwrp-g3fv","title":"Unhead has XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check","description":"## Summary\n\n`useHeadSafe()` can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered `\u003chead\u003e` tags. This is the composable that Nuxt docs recommend for safely handling user-generated content.\n\n## Details\n\n**XSS via `data-*` attribute name injection**\n\nThe `acceptDataAttrs` function (safe.ts, line 16-20) allows any property key starting with `data-` through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing.\n\n```typescript\nfunction acceptDataAttrs(value: Record\u003cstring, string\u003e) {\n  return Object.fromEntries(\n    Object.entries(value || {}).filter(([key]) =\u003e key === 'id' || key.startsWith('data-')),\n  )\n}\n```\n\nThis result gets merged into every tag's props at line 114:\n\n```typescript\ntag.props = { ...acceptDataAttrs(prev), ...next }\n```\n\nThen `propsToString` (propsToString.ts, line 26) interpolates property keys directly into the HTML string with no sanitization:\n\n```typescript\nattrs += value === true ? ` ${key}` : ` ${key}=\"${encodeAttribute(value)}\"`\n```\n\nA space in the key breaks out of the attribute name. Everything after the space becomes separate HTML attributes.\n\n### PoC\n\nThe most practical vector uses a `link` tag. `\u003clink rel=\"stylesheet\"\u003e` fires `onload` once the stylesheet loads, giving reliable script execution:\n\n```javascript\nuseHeadSafe({\n  link: [{\n    rel: 'stylesheet',\n    href: '/valid-stylesheet.css',\n    'data-x onload=alert(document.domain) y': 'z'\n  }]\n})\n```\n\nSSR output:\n\n```html\n\u003clink data-x onload=alert(document.domain) y=\"z\" rel=\"stylesheet\" href=\"/valid-stylesheet.css\"\u003e\n```\n\nThe browser parses `onload=alert(document.domain)` as its own attribute. Once the stylesheet loads, the handler fires.\n\nThe same injection works on any tag type since `acceptDataAttrs` is applied to all of them at line 114. Here's the same thing on a `meta` tag (the injected attributes render, though `onclick` doesn't fire on non-interactive `\u003cmeta\u003e` elements):\n\n```javascript\nuseHeadSafe({\n  meta: [{\n    name: 'description',\n    content: 'legitimate content',\n    'data-x onclick=alert(document.domain) y': 'z'\n  }]\n})\n```\n\n### Realistic scenario\n\nA Nuxt app accepts SEO metadata from a CMS or user profile. The developer uses `useHeadSafe()` as the docs recommend. An attacker puts a `data-*` key with spaces and an event handler into their input. The payload renders into the HTML on every page load.\n\n## Suggested fix\n\nFor vulnerability 1, validate that attribute names only contain characters legal in HTML attributes:\n\n```typescript\nconst SAFE_ATTR_RE = /^[a-zA-Z][a-zA-Z0-9\\-]*$/\n\nfunction acceptDataAttrs(value: Record\u003cstring, string\u003e) {\n  return Object.fromEntries(\n    Object.entries(value || {}).filter(\n      ([key]) =\u003e (key === 'id' || key.startsWith('data-')) \u0026\u0026 SAFE_ATTR_RE.test(key)\n    ),\n  )\n}\n```","status":"published","published":"2026-03-12T14:19:15Z","modified":"2026-03-12T19:14:27Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium"}],"cwes":["CWE-79"],"updated_at":"2026-11-02T14:39:35.644357Z"}],"nvd":[{"id":"CVE-2026-31860","description":"Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered \u003chead\u003e tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.","status":"published","published":"2026-03-12T18:16:24.227Z","modified":"2026-06-17T10:34:38.983Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"medium","assessor":"secondary"},{"version":"3.1","score":6.1,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N","level":"medium","assessor":"primary"}],"cwes":["CWE-79"],"updated_at":"2026-11-02T14:12:09.358914Z"}],"osv":[{"id":"CVE-2026-31860","title":"Unhead has a XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check","description":"Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered \u003chead\u003e tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.","status":"published","published":"2026-03-12T17:18:20.452Z","modified":"2026-10-28T02:30:24.349417438Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium"}],"cwes":["CWE-79"],"updated_at":"2026-11-02T15:45:01.218144Z"},{"id":"GHSA-g5xx-pwrp-g3fv","title":"Unhead has XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check","description":"## Summary\n\n`useHeadSafe()` can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered `\u003chead\u003e` tags. This is the composable that Nuxt docs recommend for safely handling user-generated content.\n\n## Details\n\n**XSS via `data-*` attribute name injection**\n\nThe `acceptDataAttrs` function (safe.ts, line 16-20) allows any property key starting with `data-` through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing.\n\n```typescript\nfunction acceptDataAttrs(value: Record\u003cstring, string\u003e) {\n  return Object.fromEntries(\n    Object.entries(value || {}).filter(([key]) =\u003e key === 'id' || key.startsWith('data-')),\n  )\n}\n```\n\nThis result gets merged into every tag's props at line 114:\n\n```typescript\ntag.props = { ...acceptDataAttrs(prev), ...next }\n```\n\nThen `propsToString` (propsToString.ts, line 26) interpolates property keys directly into the HTML string with no sanitization:\n\n```typescript\nattrs += value === true ? ` ${key}` : ` ${key}=\"${encodeAttribute(value)}\"`\n```\n\nA space in the key breaks out of the attribute name. Everything after the space becomes separate HTML attributes.\n\n### PoC\n\nThe most practical vector uses a `link` tag. `\u003clink rel=\"stylesheet\"\u003e` fires `onload` once the stylesheet loads, giving reliable script execution:\n\n```javascript\nuseHeadSafe({\n  link: [{\n    rel: 'stylesheet',\n    href: '/valid-stylesheet.css',\n    'data-x onload=alert(document.domain) y': 'z'\n  }]\n})\n```\n\nSSR output:\n\n```html\n\u003clink data-x onload=alert(document.domain) y=\"z\" rel=\"stylesheet\" href=\"/valid-stylesheet.css\"\u003e\n```\n\nThe browser parses `onload=alert(document.domain)` as its own attribute. Once the stylesheet loads, the handler fires.\n\nThe same injection works on any tag type since `acceptDataAttrs` is applied to all of them at line 114. Here's the same thing on a `meta` tag (the injected attributes render, though `onclick` doesn't fire on non-interactive `\u003cmeta\u003e` elements):\n\n```javascript\nuseHeadSafe({\n  meta: [{\n    name: 'description',\n    content: 'legitimate content',\n    'data-x onclick=alert(document.domain) y': 'z'\n  }]\n})\n```\n\n### Realistic scenario\n\nA Nuxt app accepts SEO metadata from a CMS or user profile. The developer uses `useHeadSafe()` as the docs recommend. An attacker puts a `data-*` key with spaces and an event handler into their input. The payload renders into the HTML on every page load.\n\n## Suggested fix\n\nFor vulnerability 1, validate that attribute names only contain characters legal in HTML attributes:\n\n```typescript\nconst SAFE_ATTR_RE = /^[a-zA-Z][a-zA-Z0-9\\-]*$/\n\nfunction acceptDataAttrs(value: Record\u003cstring, string\u003e) {\n  return Object.fromEntries(\n    Object.entries(value || {}).filter(\n      ([key]) =\u003e (key === 'id' || key.startsWith('data-')) \u0026\u0026 SAFE_ATTR_RE.test(key)\n    ),\n  )\n}\n```","status":"published","published":"2026-03-12T14:19:15Z","modified":"2026-03-14T06:26:21.163867Z","cvss":[{"version":"4.0","score":5.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N","level":"medium"}],"cwes":["CWE-79"],"updated_at":"2026-11-02T15:46:47.472172Z"},{"id":"CLSA-2026-1788988912","title":"TuxCare security update for unhead (2 CVEs)","description":"TuxCare rebuilt unhead to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-09-09T21:21:52Z","modified":"2026-10-15T09:47:55.755054109Z","updated_at":"2026-11-02T15:43:47.938828Z"},{"id":"CLSA-2026-1792161471","title":"TuxCare security update for @unhead/dom (2 CVEs)","description":"TuxCare rebuilt @unhead/dom to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-10-16T14:37:51Z","modified":"2026-10-16T16:31:03.525728277Z","updated_at":"2026-11-02T15:43:51.213191Z"},{"id":"CLSA-2026-1792161537","title":"TuxCare security update for @unhead/shared (2 CVEs)","description":"TuxCare rebuilt @unhead/shared to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-10-16T14:38:57Z","modified":"2026-10-16T16:31:05.413526794Z","updated_at":"2026-11-02T15:43:51.213191Z"},{"id":"CLSA-2026-1792161579","title":"TuxCare security update for @unhead/ssr (2 CVEs)","description":"TuxCare rebuilt @unhead/ssr to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-10-16T14:39:39Z","modified":"2026-10-16T16:30:23.848714479Z","updated_at":"2026-11-02T15:43:51.213191Z"},{"id":"CLSA-2026-1792161644","title":"TuxCare security update for @unhead/vue (2 CVEs)","description":"TuxCare rebuilt @unhead/vue to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-10-16T14:40:44Z","modified":"2026-10-16T16:31:06.614972478Z","updated_at":"2026-11-02T15:43:51.213191Z"},{"id":"CLSA-2026-1792162014","title":"TuxCare security update for @unhead/schema (2 CVEs)","description":"TuxCare rebuilt @unhead/schema to address 2 CVEs. Fixed in 1.11.20-tuxcare.1.","status":"published","published":"2026-10-16T14:46:54Z","modified":"2026-10-16T16:31:20.258162176Z","updated_at":"2026-11-02T15:43:51.466048Z"}]}}
