GHSA-73WF-GQ98-2V4G

Vulnerability from github – Published: 2026-09-01 16:41 – Updated: 2026-09-01 16:41
VLAI
Summary
Browserslist: Uncaught crash / prototype write via untrusted browserslist-stats.json custom stats (normalizeStats)
Details

Vulnerability 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):

  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.lengthundefined.lengthuncaught 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)

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.

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-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)"
}



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…