GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-C83G-RGW3-J3CX

Vulnerability from github – Published: 2026-09-01 16:42 – Updated: 2026-09-01 16:42
VLAI
Summary
Browserslist: Unbounded memory growth (no cache eviction) via distinct query results, leading to eventual OOM
Details

Vulnerability Details

File: index.js Location: cache (browserslist()'s result cache, line ~402) and parseCache (parseQueries()'s AST cache)

Root Cause

var cache = {}
var parseCache = {}

function browserslist(queries, opts) {
  ...
  var cacheKey = JSON.stringify([queries, context])
  if (cache[cacheKey]) return cache[cacheKey]
  ...
  if (!env.env.BROWSERSLIST_DISABLE_CACHE) { cache[cacheKey] = result }
  return result
}

function parseQueries(queries) {
  var cacheKey = JSON.stringify(queries)
  if (cacheKey in parseCache) return parseCache[cacheKey]
  var result = parseWithoutCache(QUERIES, queries)
  if (!env.env.BROWSERSLIST_DISABLE_CACHE) { parseCache[cacheKey] = result }
  ...
}

Every distinct (queries, context) pair is cached forever — no size cap, TTL, or eviction. browserslist.clearCaches() never resets either object (it only resets node.js's own filesystem caches); the only opt-out is the BROWSERSLIST_DISABLE_CACHE env var, controlled by the calling application, not an attacker.

Some short, valid queries amplify this badly. The since <year>-<month>-<day> query type (/^since (\d+)-(\d+)-(\d+)$/i) accepts any digit combination — Date.UTC() normalizes rather than rejects out-of-range values — giving an effectively unbounded space of ~17-byte distinct cache keys, each of which resolves to (and caches) a result close to the full ~8.5 KB browser list for any sufficiently old year.

Measured Impact

20,000 distinct since <year>-<month>-<day> queries (~330 KB total input, --expose-gc before/after measurement to rule out uncollected garbage) retained over 50 MB of heap permanently — roughly 150x amplification, growing linearly with no cap observed up to 40,000 queries (52.3 MB).

Attack Scenario

Any long-running process (server, daemon, warm CI worker) that calls browserslist() with a query value that varies across requests/items and is influenced, even partially, by external input accumulates one cache entry per distinct value ever seen. An attacker who can influence that value across many requests (this is a volumetric attack, unlike the single-request DoS findings from this same research pass) sends a stream of cheap, distinct queries (e.g. since 1900-01-01, since 1900-01-02, ...) until the process runs out of memory and crashes.

Recommended Fix (implemented and verified)

Replace both plain-object caches with Maps bounded to a fixed maximum entry count, evicting the oldest entry once the cap is reached (Map preserves insertion order, so .keys().next().value is always oldest):

var CACHE_MAX_ENTRIES = 500

function boundedCacheSet(map, key, value) {
  if (map.size >= CACHE_MAX_ENTRIES) {
    map.delete(map.keys().next().value)
  }
  map.set(key, value)
}

var cache = new Map()
var parseCache = new Map()

(read sites changed to .has()/.get(), write sites to boundedCacheSet())

Verification: - NODE_ENV=test npx uvu test .test.js → 301/301 pass unmodified (test/cache.test.js exercises clearCaches()/BROWSERSLIST_DISABLE_CACHE against node.js's separate filesystem caches, unaffected here); confirmed a repeated identical call still returns the cached reference. - Re-ran the memory PoC post-fix: heap stayed flat at ~4.9 MB after 5,000, 10,000, 20,000, and 40,000 distinct since-date queries (was 10.5 → 16.5 → 28.4 → 52.3 MB pre-fix).

Impact

  • Who is affected: Long-running processes calling browserslist() with query values that vary across requests/items and are influenced by external input.
  • What an attacker achieves: DoS via eventual out-of-memory crash, given sustained traffic over time (not a single small payload).
  • Conditions required: No authentication; requires volume rather than a single request, hence Medium rather than High severity.

Verification Environment

browserslist @ HEAD (== v4.28.6, current latest stable release) under local Node.js v20.19.5, run with --expose-gc for accurate heap measurement.

Note

Found during a broader review of this codebase in the same research pass that produced GHSA-rrmg-cfrq-23vv (parse.js algorithmic complexity), GHSA-g6p8-hj8g-x889 (baseline regexp ReDoS), GHSA-73wf-gq98-2v4g (normalizeStats crash/prototype write), and GHSA-h633-868p-5rfw (SCOPED_CONFIG__PATTERN ReDoS) — all single-request DoS vectors. This one is different in character (volumetric, not single-request) and is reported separately/scored lower accordingly.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.28.6"
      },
      "package": {
        "ecosystem": "npm",
        "name": "browserslist"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.28.7"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-73089"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-770"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-01T16:42:13Z",
    "nvd_published_at": "2026-08-11T17:19:17Z",
    "severity": "HIGH"
  },
  "details": "## Vulnerability Details\n\n**File**: `index.js`\n**Location**: `cache` (browserslist()\u0027s result cache, line ~402) and\n`parseCache` (parseQueries()\u0027s 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 \u2014 no size cap,\nTTL, or eviction. `browserslist.clearCaches()` never resets either object\n(it only resets `node.js`\u0027s 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 \u003cyear\u003e-\u003cmonth\u003e-\u003cday\u003e`\nquery type (`/^since (\\d+)-(\\d+)-(\\d+)$/i`) accepts **any** digit\ncombination \u2014 `Date.UTC()` normalizes rather than rejects out-of-range\nvalues \u2014 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 \u003cyear\u003e-\u003cmonth\u003e-\u003cday\u003e` queries (~330 KB total input,\n`--expose-gc` before/after measurement to rule out uncollected garbage)\nretained **over 50 MB** of heap permanently \u2014 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 \u003e= 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` \u2192 301/301 pass unmodified\n  (`test/cache.test.js` exercises `clearCaches()`/`BROWSERSLIST_DISABLE_CACHE`\n  against `node.js`\u0027s 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 \u2192 16.5 \u2192 28.4 \u2192 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) \u2014 all single-request DoS vectors. This one is\ndifferent in character (volumetric, not single-request) and is reported\nseparately/scored lower accordingly.",
  "id": "GHSA-c83g-rgw3-j3cx",
  "modified": "2026-09-01T16:42:13Z",
  "published": "2026-09-01T16:42:13Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/browserslist/browserslist/security/advisories/GHSA-c83g-rgw3-j3cx"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-73089"
    },
    {
      "type": "WEB",
      "url": "https://github.com/browserslist/browserslist/commit/f2931a3ff2a3a31abf84ef01a7400b270aad6405"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/browserslist/browserslist"
    },
    {
      "type": "WEB",
      "url": "https://github.com/browserslist/browserslist/releases/tag/4.28.7"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Browserslist: Unbounded memory growth (no cache eviction) via distinct query results, leading to eventual OOM"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…