CVE-2021-23371: Insufficient Information (NVD-CWE-noinfo)
value: This affects the package chrono-node before 2.2.4. It hangs on a date-like string with lots of embedded spaces.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 7.5
Published: 2021-04-12T13:15Z
Repository: https://github.com/wanasit/chrono
Language: TypeScript
Files changed: src/locales/de/constants.ts, src/locales/en/constants.ts, src/locales/fr/constants.ts, src/locales/nl/constants.ts, src/utils/pattern.ts, test/en/en_performance.test.ts, test/test_util.ts
Commit date: 2021-03-13 15:30:07 +0900
Commit message: Fix: unblounded regex backtracking in timeunit parsing

Diff: diff --git a/src/locales/de/constants.ts b/src/locales/de/constants.ts
index 9eefd34..59e0008 100644
--- a/src/locales/de/constants.ts
+++ b/src/locales/de/constants.ts
@@ -1,7 +1,7 @@
 import { OpUnitType } from "dayjs";
-import { matchAnyPattern } from "../../utils/pattern";
+import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
 import { findMostLikelyADYear } from "../../calculation/years";
 import { TimeUnits } from "../../utils/timeunits";
 
 export const WEEKDAY_DICTIONARY: { [word: string]: number } = {
     "sonntag": 0,
@@ -147,18 +147,14 @@ export function parseYear(match: string): number {
     return findMostLikelyADYear(rawYearNumber);
 }
 
 //-----------------------------
 
-const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
+const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
 const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i");
 
-const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");
-
-export const TIME_UNITS_PATTERN =
-    `(?:(?:about|around)\\s*)?` +
-    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
+export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN);
 
 export function parseTimeUnits(timeunitText): TimeUnits {
     const fragments = {};
     let remainingText = timeunitText;
     let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
diff --git a/src/locales/en/constants.ts b/src/locales/en/constants.ts
index 261f54a..0160222 100644
--- a/src/locales/en/constants.ts
+++ b/src/locales/en/constants.ts
@@ -1,7 +1,7 @@
 import { OpUnitType } from "dayjs";
-import { matchAnyPattern } from "../../utils/pattern";
+import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
 import { findMostLikelyADYear } from "../../calculation/years";
 import { TimeUnits } from "../../utils/timeunits";
 
 export const WEEKDAY_DICTIONARY: { [word: string]: number } = {
     sunday: 0,
@@ -221,18 +221,14 @@ export function parseYear(match: string): number {
     return findMostLikelyADYear(rawYearNumber);
 }
 
 //-----------------------------
 
-const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
+const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
 const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i");
 
-const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");
-
-export const TIME_UNITS_PATTERN =
-    `(?:(?:about|around)\\s*)?` +
-    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
+export const TIME_UNITS_PATTERN = repeatedTimeunitPattern(`(?:(?:about|around)\\s*)?`, SINGLE_TIME_UNIT_PATTERN);
 
 export function parseTimeUnits(timeunitText): TimeUnits {
     const fragments = {};
     let remainingText = timeunitText;
     let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
diff --git a/src/locales/fr/constants.ts b/src/locales/fr/constants.ts
index efceeb3..65617d5 100644
--- a/src/locales/fr/constants.ts
+++ b/src/locales/fr/constants.ts
@@ -1,7 +1,7 @@
 import { OpUnitType, QUnitType } from "dayjs";
-import { matchAnyPattern } from "../../utils/pattern";
+import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
 
 export const WEEKDAY_DICTIONARY: { [word: string]: number } = {
     "dimanche": 0,
     "dim": 0,
     "lundi": 1,
@@ -158,16 +158,14 @@ export function parseYear(match: string): number {
     return yearNumber;
 }
 
 //-----------------------------
 
-const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
+const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
 const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i");
 
-const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");
-
-export const TIME_UNITS_PATTERN = `(?:${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})+`;
+export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN);
 
 export function parseTimeUnits(timeunitText): { [c in OpUnitType | QUnitType]?: number } {
     const fragments = {};
     let remainingText = timeunitText;
     let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
diff --git a/src/locales/nl/constants.ts b/src/locales/nl/constants.ts
index c2ab08d..889243c 100644
--- a/src/locales/nl/constants.ts
+++ b/src/locales/nl/constants.ts
@@ -1,7 +1,7 @@
 import { OpUnitType } from "dayjs";
-import { matchAnyPattern } from "../../utils/pattern";
+import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
 import { findMostLikelyADYear } from "../../calculation/years";
 import { TimeUnits } from "../../utils/timeunits";
 
 export const WEEKDAY_DICTIONARY: { [word: string]: number } = {
     // Zondag
@@ -205,18 +205,14 @@ export function parseYear(match: string): number {
     return findMostLikelyADYear(rawYearNumber);
 }
 
 //-----------------------------
 
-const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
+const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
 const SINGLE_TIME_UNIT_REGEX = new RegExp(SINGLE_TIME_UNIT_PATTERN, "i");
 
-const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");
-
-export const TIME_UNITS_PATTERN =
-    `(?:(?:binnen|in)\\s*)?` +
-    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
+export const TIME_UNITS_PATTERN = repeatedTimeunitPattern(`(?:(?:binnen|in)\\s*)?`, SINGLE_TIME_UNIT_PATTERN);
 
 export function parseTimeUnits(timeunitText): TimeUnits {
     const fragments = {};
     let remainingText = timeunitText;
     let match = SINGLE_TIME_UNIT_REGEX.exec(remainingText);
diff --git a/src/utils/pattern.ts b/src/utils/pattern.ts
index e617609..8f087a2 100644
--- a/src/utils/pattern.ts
+++ b/src/utils/pattern.ts
@@ -1,9 +1,10 @@
 type DictionaryLike = string[] | { [word: string]: unknown } | Map<string, unknown>;
 
-export function patternWithWordBreak(regExp: RegExp): RegExp {
-    return RegExp("" + regExp.source);
+export function repeatedTimeunitPattern(prefix: string, singleTimeunitPattern: string): string {
+    const singleTimeunitPatternNoCapture = singleTimeunitPattern.replace(/\((?!\?)/g, "(?:");
+    return `${prefix}${singleTimeunitPatternNoCapture}\\s*(?:,?\\s{0,5}${singleTimeunitPatternNoCapture}){0,10}`;
 }
 
 export function extractTerms(dictionary: DictionaryLike): string[] {
     let keys: string[];
     if (dictionary instanceof Array) {
diff --git a/test/en/en_performance.test.ts b/test/en/en_performance.test.ts
new file mode 100644
index 0000000..3dd089a
--- /dev/null
+++ b/test/en/en_performance.test.ts
@@ -0,0 +1,27 @@
+import * as chrono from "../../src";
+import { measureMilliSec } from "../test_util";
+
+test("Test - Benchmarking against whitespace backtracking", () => {
+    const time = measureMilliSec(() => {
+        const str =
+            "BGR3                                                                                         " +
+            "                                                                                        186          " +
+            "                                      days                                                           " +
+            "                                                                                                     " +
+            "                                                                                                     " +
+            "           18                                                hours                                   " +
+            "                                                                                                     " +
+            "                                                                                                     " +
+            "                                   37                                                minutes         " +
+            "                                                                                                     " +
+            "                                                                                                     " +
+            "                                                             01                                      " +
+            "          seconds";
+
+        const results = chrono.parse(str);
+        expect(results.length).toBe(0);
+    });
+
+    console.log(time);
+    expect(time).toBeLessThan(1000);
+});
diff --git a/test/test_util.ts b/test/test_util.ts
index cbd6f4d..c85e425 100644
--- a/test/test_util.ts
+++ b/test/test_util.ts
@@ -1,7 +1,7 @@
 import { BufferedDebugHandler } from "../src/debugging";
-import { ParsedResult, ParsingOption } from "../src";
+import { en, ParsedResult, ParsingOption } from "../src";
 
 interface ChronoLike {
     parse(text: string, ref?: Date, option?: ParsingOption): ParsedResult[];
 }
 
@@ -71,10 +71,17 @@ export function testUnexpectedResult(chrono: ChronoLike, text: string, refDate?:
         e.stack = e.stack.replace(/[^\n]*at .*test_util.*\n/g, "");
         throw e;
     }
 }
 
+export function measureMilliSec(block: () => void): number {
+    const startTime = new Date().getMilliseconds();
+    block();
+    const endTime = new Date().getMilliseconds();
+    return endTime - startTime;
+}
+
 // --------------------------------------------------
 
 declare global {
     // eslint-disable-next-line @typescript-eslint/no-namespace
     namespace jest {

Vulnerable code: import { matchAnyPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");

export const TIME_UNITS_PATTERN =
    `(?:(?:about|around)\\s*)?` +
    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
import { matchAnyPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");

export const TIME_UNITS_PATTERN =
    `(?:(?:about|around)\\s*)?` +
    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
import { matchAnyPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");

export const TIME_UNITS_PATTERN = `(?:${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})+`;
import { matchAnyPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s*(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s*`;
const SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE = SINGLE_TIME_UNIT_PATTERN.replace(/\((?!\?)/g, "(?:");

export const TIME_UNITS_PATTERN =
    `(?:(?:binnen|in)\\s*)?` +
    `${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE}\\s*(?:,?\\s*${SINGLE_TIME_UNIT_PATTERN_NO_CAPTURE})*`;
export function patternWithWordBreak(regExp: RegExp): RegExp {
    return RegExp("" + regExp.source);
import { ParsedResult, ParsingOption } from "../src";
Fixed code: import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN);
import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
export const TIME_UNITS_PATTERN = repeatedTimeunitPattern(`(?:(?:about|around)\\s*)?`, SINGLE_TIME_UNIT_PATTERN);
import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
export const TIME_UNITS_PATTERN = repeatedTimeunitPattern("", SINGLE_TIME_UNIT_PATTERN);
import { matchAnyPattern, repeatedTimeunitPattern } from "../../utils/pattern";
const SINGLE_TIME_UNIT_PATTERN = `(${NUMBER_PATTERN})\\s{0,5}(${matchAnyPattern(TIME_UNIT_DICTIONARY)})\\s{0,5}`;
export const TIME_UNITS_PATTERN = repeatedTimeunitPattern(`(?:(?:binnen|in)\\s*)?`, SINGLE_TIME_UNIT_PATTERN);
export function repeatedTimeunitPattern(prefix: string, singleTimeunitPattern: string): string {
    const singleTimeunitPatternNoCapture = singleTimeunitPattern.replace(/\((?!\?)/g, "(?:");
    return `${prefix}${singleTimeunitPatternNoCapture}\\s*(?:,?\\s{0,5}${singleTimeunitPatternNoCapture}){0,10}`;
import * as chrono from "../../src";
import { measureMilliSec } from "../test_util";

test("Test - Benchmarking against whitespace backtracking", () => {
    const time = measureMilliSec(() => {
        const str =
            "BGR3                                                                                         " +
            "                                                                                        186          " +
            "                                      days                                                           " +
            "                                                                                                     " +
            "                                                                                                     " +
            "           18                                                hours                                   " +
            "                                                                                                     " +
            "                                                                                                     " +
            "                                   37                                                minutes         " +
            "                                                                                                     " +
            "                                                                                                     " +
            "                                                             01                                      " +
            "          seconds";

        const results = chrono.parse(str);
        expect(results.length).toBe(0);
    });

    console.log(time);
    expect(time).toBeLessThan(1000);
});
import { en, ParsedResult, ParsingOption } from "../src";
export function measureMilliSec(block: () => void): number {
    const startTime = new Date().getMilliseconds();
    block();
    const endTime = new Date().getMilliseconds();
    return endTime - startTime;
}