diff --git a/advisories/github-reviewed/2026/09/GHSA-c83g-rgw3-j3cx/GHSA-c83g-rgw3-j3cx.json b/advisories/github-reviewed/2026/09/GHSA-c83g-rgw3-j3cx/GHSA-c83g-rgw3-j3cx.json index 866b399bd78c..69f503a49ab8 100644 --- a/advisories/github-reviewed/2026/09/GHSA-c83g-rgw3-j3cx/GHSA-c83g-rgw3-j3cx.json +++ b/advisories/github-reviewed/2026/09/GHSA-c83g-rgw3-j3cx/GHSA-c83g-rgw3-j3cx.json @@ -1,7 +1,7 @@ { "schema_version": "1.4.0", "id": "GHSA-c83g-rgw3-j3cx", - "modified": "2026-09-01T16:42:13Z", + "modified": "2026-09-01T16:42:15Z", "published": "2026-09-01T16:42:13Z", "aliases": [ "CVE-2026-73089" @@ -10,8 +10,8 @@ "details": "## Vulnerability Details\n\n**File**: `index.js`\n**Location**: `cache` (browserslist()'s result cache, line ~402) and\n`parseCache` (parseQueries()'s AST cache)\n\n### Root Cause\n```js\nvar cache = {}\nvar parseCache = {}\n\nfunction browserslist(queries, opts) {\n ...\n var cacheKey = JSON.stringify([queries, context])\n if (cache[cacheKey]) return cache[cacheKey]\n ...\n if (!env.env.BROWSERSLIST_DISABLE_CACHE) { cache[cacheKey] = result }\n return result\n}\n\nfunction parseQueries(queries) {\n var cacheKey = JSON.stringify(queries)\n if (cacheKey in parseCache) return parseCache[cacheKey]\n var result = parseWithoutCache(QUERIES, queries)\n if (!env.env.BROWSERSLIST_DISABLE_CACHE) { parseCache[cacheKey] = result }\n ...\n}\n```\nEvery distinct `(queries, context)` pair is cached forever — no size cap,\nTTL, or eviction. `browserslist.clearCaches()` never resets either object\n(it only resets `node.js`'s own filesystem caches); the only opt-out is the\n`BROWSERSLIST_DISABLE_CACHE` env var, controlled by the *calling\napplication*, not an attacker.\n\nSome short, valid queries amplify this badly. The `since --`\nquery type (`/^since (\\d+)-(\\d+)-(\\d+)$/i`) accepts **any** digit\ncombination — `Date.UTC()` normalizes rather than rejects out-of-range\nvalues — giving an effectively unbounded space of ~17-byte distinct cache\nkeys, each of which resolves to (and caches) a result close to the full\n~8.5 KB browser list for any sufficiently old year.\n\n### Measured Impact\n20,000 distinct `since --` queries (~330 KB total input,\n`--expose-gc` before/after measurement to rule out uncollected garbage)\nretained **over 50 MB** of heap permanently — roughly **150x**\namplification, growing linearly with no cap observed up to 40,000 queries\n(52.3 MB).\n\n### Attack Scenario\nAny long-running process (server, daemon, warm CI worker) that calls\n`browserslist()` with a query value that varies across requests/items and is\ninfluenced, even partially, by external input accumulates one cache entry\nper distinct value ever seen. An attacker who can influence that value\nacross *many* requests (this is a volumetric attack, unlike the\nsingle-request DoS findings from this same research pass) sends a stream of\ncheap, distinct queries (e.g. `since 1900-01-01`, `since 1900-01-02`, ...)\nuntil the process runs out of memory and crashes.\n\n### Recommended Fix (implemented and verified)\nReplace both plain-object caches with `Map`s bounded to a fixed maximum\nentry count, evicting the oldest entry once the cap is reached (`Map`\npreserves insertion order, so `.keys().next().value` is always oldest):\n\n```js\nvar CACHE_MAX_ENTRIES = 500\n\nfunction boundedCacheSet(map, key, value) {\n if (map.size >= CACHE_MAX_ENTRIES) {\n map.delete(map.keys().next().value)\n }\n map.set(key, value)\n}\n\nvar cache = new Map()\nvar parseCache = new Map()\n```\n(read sites changed to `.has()`/`.get()`, write sites to `boundedCacheSet()`)\n\n**Verification**:\n- `NODE_ENV=test npx uvu test .test.js` → 301/301 pass unmodified\n (`test/cache.test.js` exercises `clearCaches()`/`BROWSERSLIST_DISABLE_CACHE`\n against `node.js`'s separate filesystem caches, unaffected here); confirmed\n a repeated identical call still returns the cached reference.\n- Re-ran the memory PoC post-fix: heap stayed flat at ~4.9 MB after 5,000,\n 10,000, 20,000, and 40,000 distinct `since`-date queries (was\n 10.5 → 16.5 → 28.4 → 52.3 MB pre-fix).\n\n### Impact\n- **Who is affected**: Long-running processes calling `browserslist()` with\n query values that vary across requests/items and are influenced by\n external input.\n- **What an attacker achieves**: DoS via eventual out-of-memory crash, given\n sustained traffic over time (not a single small payload).\n- **Conditions required**: No authentication; requires volume rather than a\n single request, hence Medium rather than High severity.\n\n### Verification Environment\nbrowserslist @ HEAD (== v4.28.6, current latest stable release) under local\nNode.js v20.19.5, run with `--expose-gc` for accurate heap measurement.\n\n### Note\nFound during a broader review of this codebase in the same research pass\nthat produced GHSA-rrmg-cfrq-23vv (parse.js algorithmic complexity),\nGHSA-g6p8-hj8g-x889 (baseline regexp ReDoS), GHSA-73wf-gq98-2v4g\n(normalizeStats crash/prototype write), and GHSA-h633-868p-5rfw\n(SCOPED_CONFIG__PATTERN ReDoS) — all single-request DoS vectors. This one is\ndifferent in character (volumetric, not single-request) and is reported\nseparately/scored lower accordingly.", "severity": [ { - "type": "CVSS_V3", - "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + "type": "CVSS_V4", + "score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N" } ], "affected": [ @@ -36,6 +36,22 @@ "database_specific": { "last_known_affected_version_range": "<= 4.28.6" } + }, + { + "package": { + "ecosystem": "Maven", + "name": "browserslist" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + } + ] + } + ] } ], "references": [ @@ -64,7 +80,7 @@ "cwe_ids": [ "CWE-770" ], - "severity": "HIGH", + "severity": "LOW", "github_reviewed": true, "github_reviewed_at": "2026-09-01T16:42:13Z", "nvd_published_at": "2026-08-11T17:19:17Z"