Browse Source

fix(security): detect nested-group ReDoS patterns in arrayParser

- Replace regex-based isUnsafePattern with character-by-character parser
- Track parenthesis nesting depth to detect deeply nested quantifiers
- Detect patterns like ((a+))+ that bypass simple regex checks
- Add tests for deeply nested patterns: ((a+))+, ((a*)+), (((x+))+)+
- Parser now catches all levels of nested quantifier groups
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
6fd112e551
  1. 15
      src/utils/__tests__/arrayParser.test.ts
  2. 33
      src/utils/arrayParser.ts

15
src/utils/__tests__/arrayParser.test.ts

@ -80,6 +80,21 @@ describe("parseTextToArray", () => {
} }
}); });
it("rejects deeply nested quantifier patterns that bypass simple regex checks", () => {
const deeplyNestedPatterns = ["((a+))+", "((a*)+)", "(((x+))+)+"];
for (const pattern of deeplyNestedPatterns) {
const result = parseTextToArray("test", {
splitMode: "regex",
delimiter: "*",
regexPattern: pattern,
trimItems: true,
removeEmpty: true,
});
expect(result.items).toEqual([]);
expect(result.error).toContain("nested quantifiers");
}
});
it("allows safe regex patterns", () => { it("allows safe regex patterns", () => {
const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"]; const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"];
for (const pattern of safePatterns) { for (const pattern of safePatterns) {

33
src/utils/arrayParser.ts

@ -18,14 +18,39 @@ const MAX_REGEX_INPUT_LENGTH = 100_000;
/** /**
* Detect regex patterns prone to catastrophic backtracking (ReDoS). * Detect regex patterns prone to catastrophic backtracking (ReDoS).
* Rejects nested quantifiers like (a+)+, (a*)+, (a+)*, etc. * Rejects nested quantifiers like (a+)+, (a*)+, ((a+))+, etc.
* Uses character-by-character parsing to track nesting depth.
*/ */
function isUnsafePattern(pattern: string): boolean { function isUnsafePattern(pattern: string): boolean {
// Strip /pattern/flags wrapper to inspect the body
const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i); const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i);
const body = slashFormat ? slashFormat[1] : pattern; const body = slashFormat ? slashFormat[1] : pattern;
// Nested quantifiers: a group containing a quantifier, followed by another quantifier
return /\([^)]*[+*]\)[+*{]/.test(body); // Track groups: when we see ')' followed by a quantifier,
// check if anything inside that group also had a quantifier.
let depth = 0;
const quantifierAtDepth: boolean[] = [];
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch === '\\') { i++; continue; } // skip escaped chars
if (ch === '(') {
depth++;
quantifierAtDepth[depth] = false;
} else if (ch === ')') {
const hadQuantifier = quantifierAtDepth[depth] || false;
depth = Math.max(0, depth - 1);
// Check if this closing paren is followed by a quantifier
const next = body[i + 1];
if (next === '+' || next === '*' || next === '{') {
if (hadQuantifier) return true; // nested quantifier!
// Mark parent depth as having a quantifier
quantifierAtDepth[depth] = true;
}
} else if ((ch === '+' || ch === '*') && depth > 0) {
quantifierAtDepth[depth] = true;
}
}
return false;
} }
function parseRegexPattern(pattern: string): RegExp { function parseRegexPattern(pattern: string): RegExp {

Loading…
Cancel
Save