CVE-2023-34109: Uncontrolled Resource Consumption (CWE-400)
value: zxcvbn-ts is an open source password strength estimator written in typescript. This vulnerability affects users running on the nodeJS platform which are using the second argument of the zxcvbn function. It can result in an unbounded resource consumption as the user inputs array is extended with every function call. Browsers are impacted, too but a single user need to do a lot of input changes so that it affects the browser, while the node process gets the inputs of every user of a platform and can be killed that way. This problem has been patched in version 3.0.2. Users are advised to upgrade. Users unable to upgrade should stop using the second argument of the zxcvbn function and use the zxcvbnOptions.setOptions function.
CWE description: The product does not properly control the allocation and maintenance of a limited resource, thereby enabling an actor to influence the amount of resources consumed, eventually leading to the exhaustion of available resources.
CVSS v3 base score: 7.5
Published: 2023-06-07T18:15Z
Repository: https://github.com/zxcvbn-ts/zxcvbn
Language: TypeScript
Files changed: packages/libraries/main/src/Options.ts
Commit date: 2023-06-06 15:05:13 +0200
Commit message: fix(user-inputs): resolve memory leak with user inputs on the fly

Diff: diff --git a/packages/libraries/main/src/Options.ts b/packages/libraries/main/src/Options.ts
index a27e9e2..76feb14 100644
--- a/packages/libraries/main/src/Options.ts
+++ b/packages/libraries/main/src/Options.ts
@@ -110,20 +110,20 @@ export class Options {
 
   setRankedDictionaries() {
     const rankedDictionaries: RankedDictionaries = {}
     const rankedDictionariesMaxWorkSize: Record<string, number> = {}
     Object.keys(this.dictionary).forEach((name) => {
-      rankedDictionaries[name] = this.getRankedDictionary(name)
+      rankedDictionaries[name] = buildRankedDictionary(this.dictionary[name])
       rankedDictionariesMaxWorkSize[name] =
-        this.getRankedDictionariesMaxWordSize(name)
+        this.getRankedDictionariesMaxWordSize(this.dictionary[name])
     })
     this.rankedDictionaries = rankedDictionaries
     this.rankedDictionariesMaxWordSize = rankedDictionariesMaxWorkSize
   }
 
-  getRankedDictionariesMaxWordSize(name: string) {
-    const data = this.dictionary[name].map((el) => {
+  getRankedDictionariesMaxWordSize(list: (string | number)[]) {
+    const data = list.map((el) => {
       if (typeof el !== 'string') {
         return el.toString().length
       }
       return el.length
     })
@@ -133,44 +133,37 @@ export class Options {
       return 0
     }
     return data.reduce((a, b) => Math.max(a, b), -Infinity)
   }
 
-  getRankedDictionary(name: string) {
-    const list = this.dictionary[name]
-    if (name === 'userInputs') {
-      const sanitizedInputs: string[] = []
-
-      list.forEach((input: string | number | boolean) => {
-        const inputType = typeof input
-        if (
-          inputType === 'string' ||
-          inputType === 'number' ||
-          inputType === 'boolean'
-        ) {
-          sanitizedInputs.push(input.toString().toLowerCase())
-        }
-      })
-
-      return buildRankedDictionary(sanitizedInputs)
-    }
-    return buildRankedDictionary(list)
+  buildSanitizedRankedDictionary(list: (string | number)[]) {
+    const sanitizedInputs: string[] = []
+
+    list.forEach((input: string | number | boolean) => {
+      const inputType = typeof input
+      if (
+        inputType === 'string' ||
+        inputType === 'number' ||
+        inputType === 'boolean'
+      ) {
+        sanitizedInputs.push(input.toString().toLowerCase())
+      }
+    })
+
+    return buildRankedDictionary(sanitizedInputs)
   }
 
   extendUserInputsDictionary(dictionary: (string | number)[]) {
-    if (this.dictionary.userInputs) {
-      this.dictionary.userInputs = [
-        ...this.dictionary.userInputs,
-        ...dictionary,
-      ]
-    } else {
-      this.dictionary.userInputs = dictionary
+    if (!this.dictionary.userInputs) {
+      this.dictionary.userInputs = []
     }
 
-    this.rankedDictionaries.userInputs = this.getRankedDictionary('userInputs')
+    const newList = [...this.dictionary.userInputs, ...dictionary]
+    this.rankedDictionaries.userInputs =
+      this.buildSanitizedRankedDictionary(newList)
     this.rankedDictionariesMaxWordSize.userInputs =
-      this.getRankedDictionariesMaxWordSize('userInputs')
+      this.getRankedDictionariesMaxWordSize(newList)
   }
 
   public addMatcher(name: string, matcher: Matcher) {
     if (this.matchers[name]) {
       console.info(`Matcher ${name} already exists`)

Vulnerable code:       rankedDictionaries[name] = this.getRankedDictionary(name)
        this.getRankedDictionariesMaxWordSize(name)
  getRankedDictionariesMaxWordSize(name: string) {
    const data = this.dictionary[name].map((el) => {
  getRankedDictionary(name: string) {
    const list = this.dictionary[name]
    if (name === 'userInputs') {
      const sanitizedInputs: string[] = []

      list.forEach((input: string | number | boolean) => {
        const inputType = typeof input
        if (
          inputType === 'string' ||
          inputType === 'number' ||
          inputType === 'boolean'
        ) {
          sanitizedInputs.push(input.toString().toLowerCase())
        }
      })

      return buildRankedDictionary(sanitizedInputs)
    }
    return buildRankedDictionary(list)
    if (this.dictionary.userInputs) {
      this.dictionary.userInputs = [
        ...this.dictionary.userInputs,
        ...dictionary,
      ]
    } else {
      this.dictionary.userInputs = dictionary
    this.rankedDictionaries.userInputs = this.getRankedDictionary('userInputs')
      this.getRankedDictionariesMaxWordSize('userInputs')
Fixed code:       rankedDictionaries[name] = buildRankedDictionary(this.dictionary[name])
        this.getRankedDictionariesMaxWordSize(this.dictionary[name])
  getRankedDictionariesMaxWordSize(list: (string | number)[]) {
    const data = list.map((el) => {
  buildSanitizedRankedDictionary(list: (string | number)[]) {
    const sanitizedInputs: string[] = []

    list.forEach((input: string | number | boolean) => {
      const inputType = typeof input
      if (
        inputType === 'string' ||
        inputType === 'number' ||
        inputType === 'boolean'
      ) {
        sanitizedInputs.push(input.toString().toLowerCase())
      }
    })

    return buildRankedDictionary(sanitizedInputs)
    if (!this.dictionary.userInputs) {
      this.dictionary.userInputs = []
    const newList = [...this.dictionary.userInputs, ...dictionary]
    this.rankedDictionaries.userInputs =
      this.buildSanitizedRankedDictionary(newList)
      this.getRankedDictionariesMaxWordSize(newList)