Unhead’s useHeadSafe() function, which Nuxt documentation recommends for injecting user-supplied content into <head> tags, contains a bypass in its hasDangerousProtocol() check. Attackers pad HTML entities for dangerous characters like : with excessive leading zeros. This evades the protocol detection, allowing javascript:, data:, or vbscript: schemes to reach SSR output. Browsers then decode the entities natively, executing malicious code in attributes like href or src.
The vulnerability stems from regex patterns in unhead/src/plugins/safe.ts that cap digits in HTML entity decoders at six hexadecimal or seven decimal digits—matching Unicode code point limits but ignoring HTML5’s allowance for unlimited leading zeros. For example, : (decimal for :, U+003A) and : (hex) exceed these caps. The decoder skips them, passes the raw string to startsWith('javascript:') (which fails), and renders it verbatim. Chrome 146+ and other browsers parse it as intended, reconstructing the blocked URI.
Root Cause
Unhead decodes entities before scheme checks, but its patterns silently fail on over-padded refs:
// Vulnerable code in unhead 2.1.12, safe.ts lines 10-11
const HtmlEntityHex = /([0-9a-f]{1,6});?/gi // Caps at 6 hex digits
const HtmlEntityDec = /(\d{1,7});?/g // Caps at 7 decimal digits
HTML5 spec (§12.2.4.69 Numeric character reference end state) permits arbitrary leading zeros. Browsers consume all digits until ;, decoding correctly. A payload like java:script:alert(1) slips through because the entity isn’t matched or replaced. This differs from CVE-2026-31860 (attribute key injection via data-* prefix), targeting value decoding instead.
Fix requires greedy regexes (e.g., /([0-9a-f]+);?/gi) followed by safe integer parsing to handle overflow or invalid code points. Unbounded matching risks DoS via huge numbers, so cap total length or parse iteratively.
Reproduction and Scope
Tested on Nuxt 4.x (current as of report), unhead 2.1.12, Node 20 LTS, Chrome 146+. Steps:
- Init project:
npx nuxi@latest init poc cd poc npm install - Replace
pages/index.vuewith auseHeadSafe()call injecting a padded payload into alinkhref, e.g.,"java:script:alert(1)". - Run
npm run dev, inspect SSR source (curl or view-page-source). Raw padded entity appears safe. - Browser DOM shows decoded
javascript:alert(1), triggering on click.
Affects any Nuxt app using useHeadSafe() for dynamic <head> like user avatars in link[rel="icon"] or OG images. Rare in practice—most head content is static—but critical if handling untrusted input.
Why This Matters and Disclosure Status
Nuxt pushes useHeadSafe() as the secure path for user content, implying protection against XSS vectors in SSR head tags. This bypass undermines that trust, exposing apps to reflected/stored XSS via protocol handlers. In finance/crypto sites (Njalla’s wheelhouse), it could leak wallets or sessions if chained with social engineering. Impact scores medium (CVSS ~6.5): requires user interaction, confined to head context, but evades touted safeguards.
Reported to Vercel HackerOne March 22, 2026—no response after 12 days. Cross-posted publicly April 3, 2026. Unhead/Nuxt teams should patch ASAP; devs, audit useHeadSafe() usage or strip entities manually with a robust decoder like he library. Skeptically, fixed-width regexes smell like premature optimization—HTML parsing demands spec fidelity over shortcuts.
Broader lesson: SSR security hinges on matching browser behavior exactly. Entity decoding isn’t trivial; libraries often cut corners. If you’re building with Nuxt/Vue, test head injections rigorously. Until fixed, avoid useHeadSafe() for untrusted data—default to server-side validation.



