CVE-2026-39315
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
Unhead is a document head and template manager. Prior to 2.1.13, useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends for rendering user-supplied content in <head> 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. The HTML5 specification imposes no limit on leading zeros in numeric character references. 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. This vulnerability is fixed in 2.1.13.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- Required
What an attacker can affect
- Confidentiality
- Low
- Integrity
- Low
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
6.1
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
Unhead is a document head and template manager. Prior to 2.1.13, useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends for rendering user-supplied content in <head> 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. The HTML5 specification imposes no limit on leading zeros in numeric character references. 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. This vulnerability is fixed in 2.1.13.
- Record
CVE-2026-39315- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-184
- Updated
- 2026-11-02
EUVDEUVD-2026-20992
6.1
Unhead is a document head and template manager. Prior to 2.1.13, useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends for rendering user-supplied content in <head> 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. The HTML5 specification imposes no limit on leading zeros in numeric character references. 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. This vulnerability is fixed in 2.1.13.
- Record
EUVD-2026-20992- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-95h2-gj7x-gx9w
6.1
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
##EVIDENCE
<img width="1900" height="855" alt="Screenshot_2026-03-25_090729" src="https://github.com/user-attachments/assets/3da93464-1caf-46ca-818f-46f8fe32ab50" /> <img width="1919" height="947" alt="Screenshot_2026-03-25_090715" src="https://github.com/user-attachments/assets/b27b1fc3-fa89-4864-99c9-4e6cff9a4e40" /> <img width="1918" height="925" alt="Screenshot_2026-03-25_090759" src="https://github.com/user-attachments/assets/9b8c94fa-d4f7-412e-ba14-214bc4103f4c" /> <img width="1912" height="812" alt="Screenshot_2026-03-25_090824" src="https://github.com/user-attachments/assets/3a4e1002-8811-453a-b08c-dfd1e42ebcf0" /> <img width="1846" height="409" alt="Screenshot_2026-03-22_090617" src="https://github.com/user-attachments/assets/9a595e13-ed18-464a-9d1a-0bb71dec96c9" />
| 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 <head> 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:
// 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)
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
npx nuxi init poc
cd poc
npm install
Step 2 — Replace pages/index.vue
<template>
<div>
<h1>useHeadSafe bypass PoC</h1>
<p>View page source or run the curl command below.</p>
</div>
</template>
<script setup>
import { useHeadSafe } from '#imports'
useHeadSafe({
link: [
// 10-digit decimal padding — exceeds \d{1,7} cap
{ rel: 'stylesheet', href: 'java:script:alert(1)'…
- Record
GHSA-95h2-gj7x-gx9w- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-184
- Updated
- 2026-11-02
NVD
6.1
Unhead is a document head and template manager. Prior to 2.1.13, useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends for rendering user-supplied content in <head> 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. The HTML5 specification imposes no limit on leading zeros in numeric character references. 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. This vulnerability is fixed in 2.1.13.
- Record
CVE-2026-39315- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-184
- Updated
- 2026-11-02
OSV
6.1
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
Unhead is a document head and template manager. Prior to 2.1.13, useHeadSafe() is the composable that Nuxt's own documentation explicitly recommends for rendering user-supplied content in <head> 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. The HTML5 specification imposes no limit on leading zeros in numeric character references. 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. This vulnerability is fixed in 2.1.13.
- Record
CVE-2026-39315- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-184
- Updated
- 2026-11-02
OSVGHSA-95h2-gj7x-gx9w
6.1
Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()
##EVIDENCE
<img width="1900" height="855" alt="Screenshot_2026-03-25_090729" src="https://github.com/user-attachments/assets/3da93464-1caf-46ca-818f-46f8fe32ab50" /> <img width="1919" height="947" alt="Screenshot_2026-03-25_090715" src="https://github.com/user-attachments/assets/b27b1fc3-fa89-4864-99c9-4e6cff9a4e40" /> <img width="1918" height="925" alt="Screenshot_2026-03-25_090759" src="https://github.com/user-attachments/assets/9b8c94fa-d4f7-412e-ba14-214bc4103f4c" /> <img width="1912" height="812" alt="Screenshot_2026-03-25_090824" src="https://github.com/user-attachments/assets/3a4e1002-8811-453a-b08c-dfd1e42ebcf0" /> <img width="1846" height="409" alt="Screenshot_2026-03-22_090617" src="https://github.com/user-attachments/assets/9a595e13-ed18-464a-9d1a-0bb71dec96c9" />
| 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 <head> 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:
// 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)
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
npx nuxi init poc
cd poc
npm install
Step 2 — Replace pages/index.vue
<template>
<div>
<h1>useHeadSafe bypass PoC</h1>
<p>View page source or run the curl command below.</p>
</div>
</template>
<script setup>
import { useHeadSafe } from '#imports'
useHeadSafe({
link: [
// 10-digit decimal padding — exceeds \d{1,7} cap
{ rel: 'stylesheet', href: 'java:script:alert(1)'…
- Record
GHSA-95h2-gj7x-gx9w- CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-184
- Updated
- 2026-11-02
Show 6 moreShow less
OSVCLSA-2026-1789031967 No score
TuxCare security update for unhead (1 CVE)
TuxCare rebuilt unhead to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1789031967- Updated
- 2026-11-02
OSVCLSA-2026-1792160937 No score
TuxCare security update for @unhead/dom (1 CVE)
TuxCare rebuilt @unhead/dom to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1792160937- Updated
- 2026-11-02
OSVCLSA-2026-1792161538 No score
TuxCare security update for @unhead/shared (1 CVE)
TuxCare rebuilt @unhead/shared to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1792161538- Updated
- 2026-11-02
OSVCLSA-2026-1792161580 No score
TuxCare security update for @unhead/ssr (1 CVE)
TuxCare rebuilt @unhead/ssr to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1792161580- Updated
- 2026-11-02
OSVCLSA-2026-1792161645 No score
TuxCare security update for @unhead/vue (1 CVE)
TuxCare rebuilt @unhead/vue to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1792161645- Updated
- 2026-11-02
OSVCLSA-2026-1792162015 No score
TuxCare security update for @unhead/schema (1 CVE)
TuxCare rebuilt @unhead/schema to address 1 CVE. Fixed in 1.11.20-tuxcare.2.
- Record
CLSA-2026-1792162015- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
unheadnpm |
before 2.1.13 | 2.1.13 |
unheadTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
@unhead/domTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
@unhead/sharedTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
@unhead/ssrTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
@unhead/vueTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
@unhead/schemaTuxCare:npm |
1.11.20-tuxcare.0 up to 1.11.20-tuxcare.2, plus listed versions 1.11.201.11.20-tuxcare.1 |
1.11.20-tuxcare.2 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-39315 is a medium vulnerability (CVSS 6.1) in Unjs Unhead, published April 9, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.30%, the 22nd percentile. Fixed in unhead 2.1.13.
References
github.com/unjs/unhead/security/advisories/GHSA-95h2-gj7x-gx9w
github.com/unjs/unhead/commit/961ea781e091853812ffe17f8cda17105d2d2299
github.com/unjs/unhead/releases/tag/v2.1.13
nvd.nist.gov/vuln/detail/CVE-2026-39315
github.com/advisories/GHSA-95h2-gj7x-gx9w
github.com/CVEProject/cvelistV5/tree/main/cves/2026/39xxx/CVE-2026-39315.json
github.com/unjs/unhead
Left out here: the rest of 2 source descriptions. The JSON record has them.