CVE-2021-23364: Inefficient Regular Expression Complexity (CWE-1333)
value: 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.
CWE description: The product uses a regular expression with an inefficient, possibly exponential worst-case computational complexity that consumes excessive CPU cycles.
CVSS v3 base score: 5.3
Published: 2021-04-28T16:15Z
Repository: https://github.com/browserslist/browserslist
Language: JSON
Files changed: index.js, package.json, test/node.test.ts
Commit date: 2021-04-08 01:24:32 -0400
Commit message: Fix unsafe regexp

Diff: diff --git a/index.js b/index.js
index 18d9bdf..16b755e 100644
--- a/index.js
+++ b/index.js
@@ -612,10 +612,72 @@ browserslist.coverage = function (browsers, stats) {
     }
     return all + (usage || 0)
   }, 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,
     select: function (context, versions) {
       return Object.keys(agents).reduce(function (selected, name) {
@@ -667,13 +729,15 @@ 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]
+        })
     }
   },
   {
     regexp: /^last\s+(\d+)\s+(\w+)\s+versions?$/i,
     select: function (context, versions, name) {
@@ -707,29 +771,34 @@ 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))
     }
   },
   {
     regexp: /^last\s+(\d*.?\d+)\s+years?$/i,
     select: function (context, years) {
       return filterByYear(Date.now() - YEAR * years, context)
     }
   },
   {
-    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+)%$/,
     select: function (context, sign, popularity) {
       popularity = parseFloat(popularity)
@@ -847,49 +916,16 @@ var QUERIES = [
         return result
       }, [])
     }
   },
   {
-    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-]+)$/,
     select: function (context, feature) {
       env.loadFeature(browserslist.cache, feature)
@@ -914,35 +950,30 @@ var QUERIES = [
       if (!e2c[toToUse]) {
         throw new BrowserslistError('Unknown version ' + to + ' of electron')
       }
       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))
         .map(function (v) {
           return 'node ' + v
@@ -974,15 +1005,17 @@ 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) {
           return 'node ' + v
         })
@@ -1020,34 +1053,27 @@ var QUERIES = [
     select: function (context, version) {
       var versionToUse = normalizeElectron(version)
       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,
     select: function (context) {
       return [env.currentNode(resolve, context)]
@@ -1055,17 +1081,21 @@ 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)
     }
   },
   {
     regexp: /^phantomjs\s+1.9$/i,
@@ -1098,11 +1128,12 @@ var QUERIES = [
           version = alias
         } else if (context.ignoreUnknownVersions) {
           return []
         } else {
           throw new BrowserslistError(
-            'Unknown version ' + version + ' of ' + name)
+            'Unknown version ' + version + ' of ' + name
+          )
         }
       }
       return [data.name + ' ' + version]
     }
   },
@@ -1140,11 +1171,12 @@ var QUERIES = [
   {
     regexp: /^(\w+)$/i,
     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)
       }
     }
   }
diff --git a/package.json b/package.json
index c7ad68a..97dfc6a 100644
--- a/package.json
+++ b/package.json
@@ -92,13 +92,20 @@
     "*.js": "eslint"
   },
   "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"
   ],
   "yaspeller": {
diff --git a/test/node.test.ts b/test/node.test.ts
index a8af674..90e083d 100644
--- a/test/node.test.ts
+++ b/test/node.test.ts
@@ -23,18 +23,12 @@ it('throws on malformed Node.js version', () => {
   }).toThrow(/Unknown/)
   expect(() => {
     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', () => {
   expect(browserslist('node 3', { ignoreUnknownVersions: true })).toEqual([])
 })

Vulnerable code:       return Object.keys(e2c).slice(-versions).map(function (i) {
        return 'chrome ' + e2c[i]
      })
      return data.versions.filter(function (v) {
        return data.released.indexOf(v) === -1
      }).map(nameMapper(data.name))
    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: /^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
    }
      return Object.keys(e2c).filter(function (i) {
        var parsed = parseFloat(i)
        return parsed >= from && parsed <= to
      }).map(function (i) {
        return 'chrome ' + e2c[i]
      })
      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
      })
          'Unknown version ' + version + ' of electron')
    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]
    }
      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)
      })
            'Unknown version ' + version + ' of ' + name)
          'Specify versions in Browserslist query for browser ' + name)
      "security/detect-unsafe-regex": "off",
    }
    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/)
Fixed code: 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
}

      return Object.keys(e2c)
        .slice(-versions)
        .map(function (i) {
          return 'chrome ' + e2c[i]
        })
      return data.versions
        .filter(function (v) {
          return data.released.indexOf(v) === -1
        })
        .map(nameMapper(data.name))
    regexp: /^since (\d+)$/i,
    select: sinceQuery
  },
  {
    regexp: /^since (\d+)-(\d+)$/i,
    select: sinceQuery
  },
  {
    regexp: /^since (\d+)-(\d+)-(\d+)$/i,
    select: sinceQuery
    regexp: /^cover\s+(\d*\.?\d+)%$/,
    select: coverQuery
  },
  {
    regexp: /^cover\s+(\d*\.?\d+)%\s+in\s+(my\s+stats|(alt-)?\w\w)$/,
    select: coverQuery
      return Object.keys(e2c)
        .filter(function (i) {
          var parsed = parseFloat(i)
          return parsed >= from && parsed <= to
        })
        .map(function (i) {
          return 'chrome ' + e2c[i]
        })
      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
        })
          'Unknown version ' + version + ' of electron'
        )
    regexp: /^node\s+(\d+)$/i,
    select: nodeQuery
  },
  {
    regexp: /^node\s+(\d+\.\d+)$/i,
    select: nodeQuery
  },
  {
    regexp: /^node\s+(\d+\.\d+\.\d+)$/i,
    select: nodeQuery
      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)
        })
            'Unknown version ' + version + ' of ' + name
          )
          'Specify versions in Browserslist query for browser ' + name
        )
    },
    "overrides": [
      {
        "files": "test/**/*",
        "rules": {
          "security/detect-unsafe-regex": "off"
        }
      }
    ]
    browserslist("node 6 - 8.a");
  }).toThrow(/Unknown/);