GHSA-73wf-gq98-2v4g: High severity npm/browserslist vulnerability
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: - NODEENV=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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/browserslistto a version that resolves this vulnerability.Fixed in 4.28.7 - Upgrade
Upgrade
browserslistto a version that resolves this vulnerability.Fixed in 4.28.6Patch GHSA-rrmg-cfrq-23vv - Upgrade
Upgrade
browserslistto a version that resolves this vulnerability.Fixed in 4.28.6Patch GHSA-g6p8-hj8g-x889 - Configuration
In normalizeStats()/getStat paths, apply an explicit hasOwnProperty guard when iterating stats keys (e.g., ensure checks like: known = Object.prototype.hasOwnProperty.call(data, i) && data[i]) so lookups such as data[i].versions.length cannot resolve to inherited Object.prototype members like __proto__/constructor/toString.
browserslist (normalizeStats) prototype-pollution key handling (hasOwnProperty guard) = use Object.prototype.hasOwnProperty.call(data, i) before accessing data[i] (and avoid bracket writes for untrusted inherited keys) - Configuration
Ensure the normalized object used for assignments is created as a null-prototype object (normalized = Object.create(null)) so bracket writes to keys like "__proto__" do not mutate the prototype.
browserslist (normalizeStats) normalized container type for writes = Object.create(null)
Event History
Frequently Asked Questions
What attacker access is needed to trigger this issue?
An attacker needs to control statistics input processed by Browserslist. This can be a browserslist-stats.json file discovered from the project directory or an ancestor, an opts.stats value supplied programmatically, or a path provided through the CLI --stats= flag.
Are applications affected even when they do not use a special Browserslist query?
Potentially, yes. getStat() is called unconditionally on every browserslist() call, and it automatically searches for browserslist-stats.json by walking upward from the project root regardless of the query.
How can I identify exposure in a project?
Check whether browserslist-stats.json exists in the project directory or any parent directory that may be searched. Also review application and build tooling calls for opts.stats usage and command invocations using --stats=.
What can be done before a fix is applied?
Prevent untrusted parties from writing or replacing browserslist-stats.json files in the project or parent directories. Do not pass attacker-controlled data through opts.stats or attacker-controlled paths through --stats=.