Regular Expression Denial of Service (ReDoS)
Description
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.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Browserslist before 4.16.5 is vulnerable to ReDoS during query parsing, enabling denial of service via crafted input.
Vulnerability
The package browserslist from version 4.0.0 and before 4.16.5 is vulnerable to Regular Expression Denial of Service (ReDoS) during parsing of user-supplied queries. The vulnerable code path resides in the regular expression used for parsing queries, which can lead to catastrophic backtracking when processing crafted input. The fix addresses this by tightening the regex to prevent excessive backtracking [1][2][4].
Exploitation
An attacker needs only the ability to supply a malicious query string to the browserslist API, such as through a web application or build tool that accepts user-defined browser queries. By crafting a specially designed input that triggers catastrophic backtracking in the vulnerable regex, the attacker can cause the Node.js process to consume excessive CPU time, effectively blocking the event loop and leading to a denial of service [3][4].
Impact
Successful exploitation leads to a denial of service condition. The affected process may become unresponsive or be terminated due to resource exhaustion, impacting the availability of applications or build pipelines that rely on browserslist for processing browser queries. There is no direct impact on confidentiality or integrity [3][4].
Mitigation
The vulnerability is fixed in version 4.16.5. Users should upgrade to 4.16.5 or later immediately. No known workarounds exist; the fix is available via the npm registry and the project's GitHub repository [1][2][4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
browserslistnpm | >= 4.0.0, < 4.16.5 | 4.16.5 |
Affected products
3- browserslist/browserslistdescription
- Range: >=4.0.0, <4.16.5
Patches
1c091916910dfFix unsafe regexp
3 files changed · +148 −115
index.js+137 −105 modified@@ -614,6 +614,68 @@ browserslist.coverage = function (browsers, stats) { }, 0) } +function nodeQuery (context, version) { + var nodeReleases = jsReleases.filter(function (i) { + return i.name === 'nodejs' + }) + var matched = nodeReleases.filter(function (i) { + return isVersionsMatch(i.version, version) + }) + if (matched.length === 0) { + if (context.ignoreUnknownVersions) { + return [] + } else { + throw new BrowserslistError('Unknown version ' + version + ' of Node.js') + } + } + return ['node ' + matched[matched.length - 1].version] +} + +function sinceQuery (context, year, month, date) { + year = parseInt(year) + month = parseInt(month || '01') - 1 + date = parseInt(date || '01') + return filterByYear(Date.UTC(year, month, date, 0, 0, 0), context) +} + +function coverQuery (context, coverage, statMode) { + coverage = parseFloat(coverage) + var usage = browserslist.usage.global + if (statMode) { + if (statMode.match(/^my\s+stats$/)) { + if (!context.customUsage) { + throw new BrowserslistError( + 'Custom usage statistics was not provided' + ) + } + usage = context.customUsage + } else { + var place + if (statMode.length === 2) { + place = statMode.toUpperCase() + } else { + place = statMode.toLowerCase() + } + env.loadCountry(browserslist.usage, place, browserslist.data) + usage = browserslist.usage[place] + } + } + var versions = Object.keys(usage).sort(function (a, b) { + return usage[b] - usage[a] + }) + var coveraged = 0 + var result = [] + var version + for (var i = 0; i <= versions.length; i++) { + version = versions[i] + if (usage[version] === 0) break + coveraged += usage[version] + result.push(version) + if (coveraged >= coverage) break + } + return result +} + var QUERIES = [ { regexp: /^last\s+(\d+)\s+major\s+versions?$/i, @@ -669,9 +731,11 @@ var QUERIES = [ { regexp: /^last\s+(\d+)\s+electron\s+versions?$/i, select: function (context, versions) { - return Object.keys(e2c).slice(-versions).map(function (i) { - return 'chrome ' + e2c[i] - }) + return Object.keys(e2c) + .slice(-versions) + .map(function (i) { + return 'chrome ' + e2c[i] + }) } }, { @@ -709,9 +773,11 @@ var QUERIES = [ regexp: /^unreleased\s+(\w+)\s+versions?$/i, select: function (context, name) { var data = checkName(name, context) - return data.versions.filter(function (v) { - return data.released.indexOf(v) === -1 - }).map(nameMapper(data.name)) + return data.versions + .filter(function (v) { + return data.released.indexOf(v) === -1 + }) + .map(nameMapper(data.name)) } }, { @@ -721,13 +787,16 @@ var QUERIES = [ } }, { - regexp: /^since (\d+)(?:-(\d+))?(?:-(\d+))?$/i, - select: function (context, year, month, date) { - year = parseInt(year) - month = parseInt(month || '01') - 1 - date = parseInt(date || '01') - return filterByYear(Date.UTC(year, month, date, 0, 0, 0), context) - } + regexp: /^since (\d+)$/i, + select: sinceQuery + }, + { + regexp: /^since (\d+)-(\d+)$/i, + select: sinceQuery + }, + { + regexp: /^since (\d+)-(\d+)-(\d+)$/i, + select: sinceQuery }, { regexp: /^(>=?|<=?)\s*(\d*\.?\d+)%$/, @@ -849,45 +918,12 @@ var QUERIES = [ } }, { - regexp: /^cover\s+(\d*\.?\d+)%(\s+in\s+(my\s+stats|(alt-)?\w\w))?$/, - select: function (context, coverage, statMode) { - coverage = parseFloat(coverage) - var usage = browserslist.usage.global - if (statMode) { - if (statMode.match(/^\s+in\s+my\s+stats$/)) { - if (!context.customUsage) { - throw new BrowserslistError( - 'Custom usage statistics was not provided' - ) - } - usage = context.customUsage - } else { - var match = statMode.match(/\s+in\s+((alt-)?\w\w)/) - var place = match[1] - if (place.length === 2) { - place = place.toUpperCase() - } else { - place = place.toLowerCase() - } - env.loadCountry(browserslist.usage, place, browserslist.data) - usage = browserslist.usage[place] - } - } - var versions = Object.keys(usage).sort(function (a, b) { - return usage[b] - usage[a] - }) - var coveraged = 0 - var result = [] - var version - for (var i = 0; i <= versions.length; i++) { - version = versions[i] - if (usage[version] === 0) break - coveraged += usage[version] - result.push(version) - if (coveraged >= coverage) break - } - return result - } + regexp: /^cover\s+(\d*\.?\d+)%$/, + select: coverQuery + }, + { + regexp: /^cover\s+(\d*\.?\d+)%\s+in\s+(my\s+stats|(alt-)?\w\w)$/, + select: coverQuery }, { regexp: /^supports\s+([\w-]+)$/, @@ -916,31 +952,26 @@ var QUERIES = [ } from = parseFloat(from) to = parseFloat(to) - return Object.keys(e2c).filter(function (i) { - var parsed = parseFloat(i) - return parsed >= from && parsed <= to - }).map(function (i) { - return 'chrome ' + e2c[i] - }) + return Object.keys(e2c) + .filter(function (i) { + var parsed = parseFloat(i) + return parsed >= from && parsed <= to + }) + .map(function (i) { + return 'chrome ' + e2c[i] + }) } }, { regexp: /^node\s+([\d.]+)\s*-\s*([\d.]+)$/i, select: function (context, from, to) { - var nodeVersions = jsReleases.filter(function (i) { - return i.name === 'nodejs' - }).map(function (i) { - return i.version - }) - var semverRegExp = /^(0|[1-9]\d*)(\.(0|[1-9]\d*)){0,2}$/ - if (!semverRegExp.test(from)) { - throw new BrowserslistError( - 'Unknown version ' + from + ' of Node.js') - } - if (!semverRegExp.test(to)) { - throw new BrowserslistError( - 'Unknown version ' + to + ' of Node.js') - } + var nodeVersions = jsReleases + .filter(function (i) { + return i.name === 'nodejs' + }) + .map(function (i) { + return i.version + }) return nodeVersions .filter(semverFilterLoose('>=', from)) .filter(semverFilterLoose('<=', to)) @@ -976,11 +1007,13 @@ var QUERIES = [ { regexp: /^node\s*(>=?|<=?)\s*([\d.]+)$/i, select: function (context, sign, version) { - var nodeVersions = jsReleases.filter(function (i) { - return i.name === 'nodejs' - }).map(function (i) { - return i.version - }) + var nodeVersions = jsReleases + .filter(function (i) { + return i.name === 'nodejs' + }) + .map(function (i) { + return i.version + }) return nodeVersions .filter(generateSemverFilter(sign, version)) .map(function (v) { @@ -1022,30 +1055,23 @@ var QUERIES = [ var chrome = e2c[versionToUse] if (!chrome) { throw new BrowserslistError( - 'Unknown version ' + version + ' of electron') + 'Unknown version ' + version + ' of electron' + ) } return ['chrome ' + chrome] } }, { - regexp: /^node\s+(\d+(\.\d+)?(\.\d+)?)$/i, - select: function (context, version) { - var nodeReleases = jsReleases.filter(function (i) { - return i.name === 'nodejs' - }) - var matched = nodeReleases.filter(function (i) { - return isVersionsMatch(i.version, version) - }) - if (matched.length === 0) { - if (context.ignoreUnknownVersions) { - return [] - } else { - throw new BrowserslistError( - 'Unknown version ' + version + ' of Node.js') - } - } - return ['node ' + matched[matched.length - 1].version] - } + regexp: /^node\s+(\d+)$/i, + select: nodeQuery + }, + { + regexp: /^node\s+(\d+\.\d+)$/i, + select: nodeQuery + }, + { + regexp: /^node\s+(\d+\.\d+\.\d+)$/i, + select: nodeQuery }, { regexp: /^current\s+node$/i, @@ -1057,13 +1083,17 @@ var QUERIES = [ regexp: /^maintained\s+node\s+versions$/i, select: function (context) { var now = Date.now() - var queries = Object.keys(jsEOL).filter(function (key) { - return now < Date.parse(jsEOL[key].end) && - now > Date.parse(jsEOL[key].start) && - isEolReleased(key) - }).map(function (key) { - return 'node ' + key.slice(1) - }) + var queries = Object.keys(jsEOL) + .filter(function (key) { + return ( + now < Date.parse(jsEOL[key].end) && + now > Date.parse(jsEOL[key].start) && + isEolReleased(key) + ) + }) + .map(function (key) { + return 'node ' + key.slice(1) + }) return resolve(queries, context) } }, @@ -1100,7 +1130,8 @@ var QUERIES = [ return [] } else { throw new BrowserslistError( - 'Unknown version ' + version + ' of ' + name) + 'Unknown version ' + version + ' of ' + name + ) } } return [data.name + ' ' + version] @@ -1142,7 +1173,8 @@ var QUERIES = [ select: function (context, name) { if (byName(name, context)) { throw new BrowserslistError( - 'Specify versions in Browserslist query for browser ' + name) + 'Specify versions in Browserslist query for browser ' + name + ) } else { throw unknownQuery(name) }
package.json+9 −2 modified@@ -94,9 +94,16 @@ "eslintConfig": { "extends": "@logux/eslint-config/browser", "rules": { - "security/detect-unsafe-regex": "off", "global-require": "off" - } + }, + "overrides": [ + { + "files": "test/**/*", + "rules": { + "security/detect-unsafe-regex": "off" + } + } + ] }, "eslintIgnore": [ "test/fixtures"
test/node.test.ts+2 −8 modified@@ -25,14 +25,8 @@ it('throws on malformed Node.js version', () => { browserslist('node 8.01') }).toThrow(/Unknown/) expect(() => { - browserslist('node 6 - 8.a') - }).toThrow(/Unknown/) - expect(() => { - browserslist('node 6.6.6.6 - 8') - }).toThrow(/Unknown/) - expect(() => { - browserslist('node 6 - 8.01') - }).toThrow(/Unknown/) + browserslist("node 6 - 8.a"); + }).toThrow(/Unknown/); }) it('return empty array on unknown Node.js version with special flag', () => {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-w8qv-6jwh-64r5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-23364ghsaADVISORY
- github.com/browserslist/browserslist/blob/e82f32d1d4100d6bc79ea0b6b6a2d281a561e33c/index.js%23L472-L474ghsax_refsource_MISCWEB
- github.com/browserslist/browserslist/commit/c091916910dfe0b5fd61caad96083c6709b02d98ghsax_refsource_MISCWEB
- github.com/browserslist/browserslist/pull/593ghsax_refsource_MISCWEB
- snyk.io/vuln/SNYK-JAVA-ORGWEBJARSNPM-1277182ghsax_refsource_MISCWEB
- snyk.io/vuln/SNYK-JS-BROWSERSLIST-1090194ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.