Apr 10, 2026·6 min read·3 visits
A regular expression limitation in Unhead versions prior to 2.1.13 allows attackers to bypass XSS filters by padding HTML entities with leading zeros. This enables the execution of malicious javascript: URIs via the useHeadSafe() API.
The Unhead document head manager contains a security bypass vulnerability in the useHeadSafe() composable. An incomplete regular expression fails to decode HTML numeric character references padded with excessive leading zeros, allowing attackers to bypass protocol blocklists and achieve Cross-Site Scripting (XSS).
The unhead package is a document head manager widely utilized within the Vue.js and Nuxt.js ecosystems. The useHeadSafe() composable restricts document <head> injections to safe tags and attributes to prevent Cross-Site Scripting (XSS) vulnerabilities. This vulnerability, tracked as CVE-2026-39315, breaks this safety guarantee by allowing attackers to bypass internal protocol blocklists.
The underlying bug class is CWE-184 (Incomplete List of Disallowed Inputs). The sanitization logic fails to account for arbitrary padding in HTML numeric character references. By injecting excessive leading zeros, an attacker ensures the malicious input evades the intermediate sanitization phase while remaining perfectly valid for the downstream browser HTML parser.
The primary impact is Cross-Site Scripting (XSS) in applications leveraging Server-Side Rendering (SSR). An attacker can inject arbitrary JavaScript execution vectors through attributes that accept URIs, fundamentally undermining the stated purpose of the useHeadSafe() security API.
The flaw originates in the hasDangerousProtocol() function located within packages/unhead/src/plugins/safe.ts. This function evaluates strings for blocked URI schemes such as javascript:, data:, and vbscript:. To prevent simple obfuscation, the function attempts to decode HTML entities before executing the protocol string matching logic.
The decoding operation relies on two regular expressions: /&#x([0-9a-f]{1,6});?/gi for hexadecimal entities and /&#(\d{1,7});?/g for decimal entities. These patterns explicitly restrict the maximum length of the captured digits to 6 and 7 characters, respectively. The author likely derived these limits from standard Unicode codepoint limits without considering the HTML5 specification's handling of numeric entity padding.
According to the HTML5 specification, numeric character references can contain an arbitrary number of leading zeros. The browser parser seamlessly strips these zeros during the decoding phase. Because the unhead regular expressions enforce strict length caps, any padded entity exceeding the maximum character limit completely fails to match the regex. Consequently, the input string retains its encoded form during the sanitization phase, successfully evading the subsequent startsWith('javascript:') validation check.
The vulnerability stems directly from the hardcoded quantifiers in the entity decoding logic. Prior to version 2.1.13, the string evaluation explicitly enforced maximum lengths. The function makeTagSafe() utilizes these evaluations to determine if a tag attribute contains a dangerous protocol.
When an attacker submits a payload containing :, the string length of the numeric segment is 10 digits. The HtmlEntityDec regex evaluates this input and returns no match due to the {1,7} constraint. The text remains unchanged, and the protocol check evaluates javascript:, returning false. The commit 961ea781e091853812ffe17f8cda17105d2d2299 addresses this by modifying the regular expression quantifiers.
// packages/unhead/src/plugins/safe.ts
- const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi
- const HtmlEntityDec = /&#(\d{1,7});?/g
+ const HtmlEntityHex = /&#x([0-9a-f]+);?/gi
+ const HtmlEntityDec = /&#(\d+);?/gBy utilizing the + quantifier, the regex captures one or more digits indefinitely. This ensures that any arbitrarily padded entity correctly matches the expression, triggering the internal decode operation. The subsequent protocol validation check therefore operates on the fully normalized string, appropriately identifying and blocking restricted URI protocols.
Exploitation requires an application configuration where user-controlled input flows directly into the useHeadSafe() composable. Attackers target attributes mapped to URL inputs, such as the href attribute in a <link> tag. The payload constructs a malicious URI scheme but obscures the required colon character using a padded numeric character reference.
The proof-of-concept payload javascript:alert('XSS') demonstrates this bypass sequence. The string passes through the unhead validation logic unmodified, as the colon obfuscation exceeds the seven-digit regex limit. The makeTagSafe() function accepts the input as benign and incorporates it into the final Server-Side Rendered (SSR) HTML payload.
Upon receiving the HTML response, the victim's browser begins parsing the document <head>. The browser's native HTML parser processes the numeric entity according to the HTML5 specification, discarding the leading zeros and resolving : to the colon character :. The browser immediately executes the reconstructed javascript:alert('XSS') payload within the security context of the vulnerable application.
The exploitation of CVE-2026-39315 results in a standard Cross-Site Scripting (XSS) vulnerability. An attacker successfully executes arbitrary JavaScript within the victim's browser session. The vulnerability holds a CVSS v3.1 base score of 6.1, reflecting a medium severity rating due to the requirements for user interaction and specific input configurations.
The impact scope heavily depends on the privileges of the compromised user session and the implementation details of the targeted application. Executed scripts can access document.cookie, intercept session tokens, exfiltrate sensitive data displayed on the page, or perform unauthorized actions on behalf of the victim. In environments without strict Content Security Policy (CSP) enforcement, the attacker maintains complete control over the execution context.
Because useHeadSafe() is explicitly designed and advertised as a security mechanism for rendering untrusted user data, this bypass undermines the core trust model of the component. Developers relying on this composable to handle arbitrary data streams implicitly introduce an XSS vector into their Server-Side Rendered applications.
The primary remediation strategy is upgrading the unhead package to version 2.1.13 or later. This release contains the updated regular expressions that correctly parse and normalize arbitrarily padded numeric character references. Developers should audit application dependency trees, particularly within Nuxt.js projects, to ensure transitive dependencies are updated accordingly.
If immediate patching is unfeasible, developers must implement secondary input validation before passing data into useHeadSafe(). Application logic can enforce strict allowlists for acceptable URI schemes or explicitly reject inputs containing the &# string sequence within URL-destined attributes.
Defense-in-depth measures provide critical fallback protection against this class of vulnerability. Implementing a robust Content Security Policy (CSP) that explicitly prohibits unsafe-inline and restricts script execution to trusted domains effectively neutralizes the javascript: URI attack vector, even if the application logic fails to filter the payload.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
unhead unjs | < 2.1.13 | 2.1.13 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-184 |
| CVSS Score | 6.1 (Medium) |
| Attack Vector | Network |
| Exploit Status | PoC Available |
| CISA KEV | Not Listed |
| Impact | Cross-Site Scripting (XSS) |
The software does not maintain a complete list of disallowed inputs, enabling bypass of protection mechanisms.