GitHub Security Advisory: GHSA-95h2-gj7x-gx9w
Package: unhead (NPM)
Severity: MODERATE (CVSS 6.1)
CWE: CWE-184: Incomplete List of Disallowed Inputs
Vulnerable versions: < 2.1.13
Patched in: 2.1.13

Summary: Unhead has a hasDangerousProtocol() bypass via leading-zero padded HTML entities in useHeadSafe()

Details:
##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:

```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):

- `&#[PII_REDACTED];` — 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 c