Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 <year>-<month>-<day>`\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 <year>-<month>-<day>` 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": [
Expand All @@ -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": [
Expand Down Expand Up @@ -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"
Expand Down
Loading