GHSA-73WF-GQ98-2V4G
Vulnerability from github – Published: 2026-09-01 16:41 – Updated: 2026-09-01 16:41Vulnerability Details
File: node.js
Function: normalizeStats() (line ~214), reached from getStat() (called
unconditionally on every browserslist() call) and loadStat()
Root Cause
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):
- Crash:
data[i]has nohasOwnPropertyguard. Ifstatscontains a key that also happens to be an inheritedObject.prototypemember name —"__proto__","toString","valueOf","constructor","hasOwnProperty","isPrototypeOf", etc. —data[i]resolves to that inherited function/object (always truthy), and the code then doesdata[i].versions.length→undefined.length→ uncaughtTypeError, for any such key whose JSON value has exactly one sub-key, e.g.:json { "toString": { "onekey": 5 }, "chrome": { "100": 50 } } - Prototype write:
normalized[i] = ...on the freshnormalized = {}— ifiis exactly"__proto__"(andnormalizedhas no own property by that name yet), this computed assignment invokes the realObject.prototype.__proto__setter, changingnormalized'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
- Attacker submits a PR (or a compromised dependency) adding a
browserslist-stats.jsonfile anywhere between the project root and filesystem root, containing e.g.{"toString": {"onekey": 5}, "chrome": {"100": 50}}. - The victim's build/CI pipeline runs any tool that calls
browserslist()internally, for any query. - The auto-discovered poisoned file crashes the process with an uncaught
TypeErroron 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)
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.
{
"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-73088"
],
"database_specific": {
"cwe_ids": [
"CWE-1321",
"CWE-248"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-01T16:41:54Z",
"nvd_published_at": "2026-08-11T17:19:16Z",
"severity": "HIGH"
},
"details": "## Vulnerability Details\n\n**File**: `node.js`\n**Function**: `normalizeStats()` (line ~214), reached from `getStat()` (called\n**unconditionally** on every `browserslist()` call) and `loadStat()`\n\n### Root Cause\n```js\nfunction normalizeStats(data, stats) {\n if (!data) { data = {} }\n if (stats \u0026\u0026 \u0027dataByBrowser\u0027 in stats) { stats = stats.dataByBrowser }\n if (typeof stats !== \u0027object\u0027) return undefined\n\n var normalized = {}\n for (var i in stats) {\n var versions = Object.keys(stats[i])\n if (versions.length === 1 \u0026\u0026 data[i] \u0026\u0026 data[i].versions.length === 1) {\n var normal = data[i].versions[0]\n normalized[i] = {}\n normalized[i][normal] = stats[i][versions[0]]\n } else {\n normalized[i] = stats[i]\n }\n }\n return normalized\n}\n```\n`stats` is untrusted: it comes from `JSON.parse()`-ing a\n`browserslist-stats.json` file \u2014 auto-discovered by walking up the directory\ntree from the project root **on every `browserslist()` call, regardless of\nthe query** (`env.getStat(opts, browserslist.data)` runs unconditionally\ninside `browserslist()`) \u2014 or from `opts.stats` passed programmatically /\nvia the CLI\u0027s `--stats=` flag. `data` is `browserslist.data`, a plain object\npopulated only with real browser names.\n\nTwo independent bugs from the same root cause (unguarded `for...in` over\nuntrusted keys used with plain-object bracket access/assignment):\n\n1. **Crash**: `data[i]` has no `hasOwnProperty` guard. If `stats` contains a\n key that also happens to be an inherited `Object.prototype` member name \u2014\n `\"__proto__\"`, `\"toString\"`, `\"valueOf\"`, `\"constructor\"`,\n `\"hasOwnProperty\"`, `\"isPrototypeOf\"`, etc. \u2014 `data[i]` resolves to that\n inherited function/object (always truthy), and the code then does\n `data[i].versions.length` \u2192 `undefined.length` \u2192 **uncaught `TypeError`**,\n for any such key whose JSON value has exactly one sub-key, e.g.:\n ```json\n { \"toString\": { \"onekey\": 5 }, \"chrome\": { \"100\": 50 } }\n ```\n2. **Prototype write**: `normalized[i] = ...` on the fresh\n `normalized = {}` \u2014 if `i` is exactly `\"__proto__\"` (and `normalized` has\n no own property by that name yet), this computed assignment invokes the\n real `Object.prototype.__proto__` setter, changing `normalized`\u0027s actual\n `[[Prototype]]` instead of creating a plain property.\n\nBecause this runs on **every** `browserslist()` call regardless of the\nquery, simply committing a poisoned `browserslist-stats.json` anywhere in a\nproject\u0027s directory tree breaks every subsequent Browserslist call in that\nproject \u2014 including calls made by Autoprefixer, Babel `preset-env`,\nStylelint, or PostCSS internally, for completely unrelated queries.\n\n### Attack Scenario\n1. Attacker submits a PR (or a compromised dependency) adding a\n `browserslist-stats.json` file anywhere between the project root and\n filesystem root, containing e.g.\n `{\"toString\": {\"onekey\": 5}, \"chrome\": {\"100\": 50}}`.\n2. The victim\u0027s build/CI pipeline runs any tool that calls `browserslist()`\n internally, for **any** query.\n3. The auto-discovered poisoned file crashes the process with an uncaught\n `TypeError` on the very first call.\n\n### Measured Impact\nConfirmed crash (real `browserslist()` call, v4.28.6) with `stats` keys:\n`__proto__`, `toString`, `valueOf`, `hasOwnProperty`, `constructor`,\n`isPrototypeOf` \u2014 each paired with a one-key JSON object \u2014 for any query,\nincluding `browserslist(\u0027defaults\u0027)` which never mentions stats.\n\n### Recommended Fix (implemented and verified)\n```js\nvar normalized = Object.create(null)\nfor (var i in stats) {\n var versions = Object.keys(stats[i])\n var known = Object.prototype.hasOwnProperty.call(data, i) \u0026\u0026 data[i]\n if (versions.length === 1 \u0026\u0026 known \u0026\u0026 known.versions.length === 1) {\n var normal = known.versions[0]\n normalized[i] = Object.create(null)\n normalized[i][normal] = stats[i][versions[0]]\n } else {\n normalized[i] = stats[i]\n }\n}\nreturn normalized\n```\n`normalized` uses `Object.create(null)` so a write to `\"__proto__\"` is an\nordinary property set, never a `[[Prototype]]` change; `data[i]` is replaced\nwith an explicit `hasOwnProperty` check so it never resolves to an inherited\n`Object.prototype` member.\n\n**Verification**:\n- `NODE_ENV=test npx uvu test .test.js` \u2192 301/301 pass unmodified\n (`test/custom.test.js`, `test/shareable-stats.test.js`, `test/cover.test.js`\n exercise the stats-handling paths).\n- All 6 previously crash-inducing keys, tested individually, now resolve\n without error.\n- The realistic file-based auto-discovery scenario (poisoned\n `browserslist-stats.json` + an unrelated `browserslist(\u0027defaults\u0027)` call)\n now returns a normal result instead of crashing.\n\n### Impact\n- **Who is affected**: Any project whose build/CI invokes Browserslist\n (directly or via Autoprefixer/Babel/Stylelint/PostCSS) in a directory tree\n an attacker can place a file into (external PR, compromised dependency),\n or any app that passes user-influenced data into `opts.stats`.\n- **What an attacker achieves**: Immediate DoS \u2014 crashes the invoking\n process on the first Browserslist call after the file is present, for any\n query, no special syntax needed.\n- **Conditions required**: No authentication \u2014 only the ability to add a\n file to the project\u0027s directory tree, or influence `opts.stats`.\n\n### Verification Environment\nbrowserslist @ HEAD (== v4.28.6, current latest stable release) under local\nNode.js v20.19.5. Pure JS library \u2014 executed directly, no server needed.\n\n### Note\nFound via a systematic review of prototype-pollution-adjacent patterns in\nthis codebase after confirming two unrelated algorithmic-complexity issues\n(reported separately as GHSA-rrmg-cfrq-23vv and GHSA-g6p8-hj8g-x889) in the\nsame research pass. A similar `for...in` + bracket-write pattern in\n`index.js`\u0027s `copyObject()` (used by `normalizeAndroidData`) was already\nguarded against `__proto__`/`constructor`/`prototype` keys by a prior,\nunrelated commit \u2014 that guard was never applied to this function.",
"id": "GHSA-73wf-gq98-2v4g",
"modified": "2026-09-01T16:41:54Z",
"published": "2026-09-01T16:41:54Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/browserslist/browserslist/security/advisories/GHSA-73wf-gq98-2v4g"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-73088"
},
{
"type": "WEB",
"url": "https://github.com/browserslist/browserslist/commit/f9914ad9effc865ccc27d816255625890b31ca51"
},
{
"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: Uncaught crash / prototype write via untrusted browserslist-stats.json custom stats (normalizeStats)"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.