3 known bugs in browserslist, with affected versions, fixes and workarounds. Sourced from upstream issue trackers.
| Severity | Affected | Fixed in | Title | Status | Source |
|---|
| high | any | 4.28.7 | Browserslist: Unbounded memory growth (no cache eviction) via distinct query results, leading to eventual OOM ## Vulnerability Details
**File**: `index.js`
**Location**: `cache` (browserslist()'s result cache, line ~402) and
`parseCache` (parseQueries()'s AST cache)
### Root Cause
```js
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 `Map`s 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):
```js
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. | fixed | osv:GHSA-c83g-rgw3-j3cx |
| high | any | 4.28.7 | Browserslist: Uncaught crash / prototype write via untrusted browserslist-stats.json custom stats (normalizeStats) ## Vulnerability Details
**File**: `node.js`
**Function**: `normalizeStats()` (line ~214), reached from `getStat()` (called
**unconditionally** on every `browserslist()` call) and `loadStat()`
### Root Cause
```js
function normalizeStats(data, stats) {
if (!data) { data = {} }
if (stats && 'dataByBrowser' in stats) { stats = stats.dataByBrowser }
if (typeof stats !== 'object') return undefined
var normalized = {}
for (var i in stats) {
var versions = Object.keys(stats[i])
if (versions.length === 1 && data[i] && data[i].versions.length === 1) {
var normal = data[i].versions[0]
normalized[i] = {}
normalized[i][normal] = stats[i][versions[0]]
} else {
normalized[i] = stats[i]
}
}
return normalized
}
```
`stats` is untrusted: it comes from `JSON.parse()`-ing a
`browserslist-stats.json` file — auto-discovered by walking up the directory
tree from the project root **on every `browserslist()` call, regardless of
the query** (`env.getStat(opts, browserslist.data)` runs unconditionally
inside `browserslist()`) — or from `opts.stats` passed programmatically /
via the CLI's `--stats=` flag. `data` is `browserslist.data`, a plain object
populated only with real browser names.
Two independent bugs from the same root cause (unguarded `for...in` over
untrusted keys used with plain-object bracket access/assignment):
1. **Crash**: `data[i]` has no `hasOwnProperty` guard. If `stats` contains a
key that also happens to be an inherited `Object.prototype` member name —
`"__proto__"`, `"toString"`, `"valueOf"`, `"constructor"`,
`"hasOwnProperty"`, `"isPrototypeOf"`, etc. — `data[i]` resolves to that
inherited function/object (always truthy), and the code then does
`data[i].versions.length` → `undefined.length` → **uncaught `TypeError`**,
for any such key whose JSON value has exactly one sub-key, e.g.:
```json
{ "toString": { "onekey": 5 }, "chrome": { "100": 50 } }
```
2. **Prototype write**: `normalized[i] = ...` on the fresh
`normalized = {}` — if `i` is exactly `"__proto__"` (and `normalized` has
no own property by that name yet), this computed assignment invokes the
real `Object.prototype.__proto__` setter, changing `normalized`'s actual
`[[Prototype]]` instead of creating a plain property.
Because this runs on **every** `browserslist()` call regardless of the
query, simply committing a poisoned `browserslist-stats.json` anywhere in a
project's directory tree breaks every subsequent Browserslist call in that
project — including calls made by Autoprefixer, Babel `preset-env`,
Stylelint, or PostCSS internally, for completely unrelated queries.
### Attack Scenario
1. Attacker submits a PR (or a compromised dependency) adding a
`browserslist-stats.json` file anywhere between the project root and
filesystem root, containing e.g.
`{"toString": {"onekey": 5}, "chrome": {"100": 50}}`.
2. The victim's build/CI pipeline runs any tool that calls `browserslist()`
internally, for **any** query.
3. The auto-discovered poisoned file crashes the process with an uncaught
`TypeError` on the very first call.
### Measured Impact
Confirmed crash (real `browserslist()` call, v4.28.6) with `stats` keys:
`__proto__`, `toString`, `valueOf`, `hasOwnProperty`, `constructor`,
`isPrototypeOf` — each paired with a one-key JSON object — for any query,
including `browserslist('defaults')` which never mentions stats.
### Recommended Fix (implemented and verified)
```js
var normalized = Object.create(null)
for (var i in stats) {
var versions = Object.keys(stats[i])
var known = Object.prototype.hasOwnProperty.call(data, i) && data[i]
if (versions.length === 1 && known && known.versions.length === 1) {
var normal = known.versions[0]
normalized[i] = Object.create(null)
normalized[i][normal] = stats[i][versions[0]]
} else {
normalized[i] = stats[i]
}
}
return normalized
```
`normalized` uses `Object.create(null)` so a write to `"__proto__"` is an
ordinary property set, never a `[[Prototype]]` change; `data[i]` is replaced
with an explicit `hasOwnProperty` check so it never resolves to an inherited
`Object.prototype` member.
**Verification**:
- `NODE_ENV=test npx uvu test .test.js` → 301/301 pass unmodified
(`test/custom.test.js`, `test/shareable-stats.test.js`, `test/cover.test.js`
exercise the stats-handling paths).
- All 6 previously crash-inducing keys, tested individually, now resolve
without error.
- The realistic file-based auto-discovery scenario (poisoned
`browserslist-stats.json` + an unrelated `browserslist('defaults')` call)
now returns a normal result instead of crashing.
### Impact
- **Who is affected**: Any project whose build/CI invokes Browserslist
(directly or via Autoprefixer/Babel/Stylelint/PostCSS) in a directory tree
an attacker can place a file into (external PR, compromised dependency),
or any app that passes user-influenced data into `opts.stats`.
- **What an attacker achieves**: Immediate DoS — crashes the invoking
process on the first Browserslist call after the file is present, for any
query, no special syntax needed.
- **Conditions required**: No authentication — only the ability to add a
file to the project's directory tree, or influence `opts.stats`.
### Verification Environment
browserslist @ HEAD (== v4.28.6, current latest stable release) under local
Node.js v20.19.5. Pure JS library — executed directly, no server needed.
### Note
Found via a systematic review of prototype-pollution-adjacent patterns in
this codebase after confirming two unrelated algorithmic-complexity issues
(reported separately as GHSA-rrmg-cfrq-23vv and GHSA-g6p8-hj8g-x889) in the
same research pass. A similar `for...in` + bracket-write pattern in
`index.js`'s `copyObject()` (used by `normalizeAndroidData`) was already
guarded against `__proto__`/`constructor`/`prototype` keys by a prior,
unrelated commit — that guard was never applied to this function. | ||
| medium | 4.0.0 | 4.16.5 | Regular Expression Denial of Service in browserslist The package browserslist from 4.0.0 and before 4.16.5 are vulnerable to Regular Expression Denial of Service (ReDoS) during parsing of queries. | fixed | osv:GHSA-w8qv-6jwh-64r5 |
Get this data programmatically \u2014 free, no authentication.
curl https://depscope.dev/api/bugs/npm/browserslist| fixed |
| osv:GHSA-73wf-gq98-2v4g |