Browse Source

fix: add ReDoS protection for regex mode and minimap color for array node

Reject patterns with nested quantifiers (e.g. `(a+)+`) that cause
catastrophic backtracking, cap regex input at 100K chars, and add
lime-400 minimap color for array nodes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
3d1f276ab5
  1. 2
      src/components/WorkflowCanvas.tsx
  2. 55
      src/utils/__tests__/arrayParser.test.ts
  3. 23
      src/utils/arrayParser.ts

2
src/components/WorkflowCanvas.tsx

@ -1714,6 +1714,8 @@ export function WorkflowCanvas() {
return "#8b5cf6"; return "#8b5cf6";
case "prompt": case "prompt":
return "#f97316"; return "#f97316";
case "array":
return "#a3e635";
case "promptConstructor": case "promptConstructor":
return "#f472b6"; return "#f472b6";
case "nanoBanana": case "nanoBanana":

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

@ -65,6 +65,61 @@ describe("parseTextToArray", () => {
expect(result.error).toBeTruthy(); expect(result.error).toBeTruthy();
}); });
it("rejects nested quantifier patterns (ReDoS)", () => {
const dangerousPatterns = ["(a+)+", "(a*)*", "(a+)*", "/(a+)+/g", "(x*)+"];
for (const pattern of dangerousPatterns) {
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", () => {
const safePatterns = ["\\d+", "[,;]+", "\\s+", "(a|b)"];
for (const pattern of safePatterns) {
const result = parseTextToArray("a1b2c", {
splitMode: "regex",
delimiter: "*",
regexPattern: pattern,
trimItems: true,
removeEmpty: true,
});
expect(result.error).toBeNull();
expect(result.items.length).toBeGreaterThan(0);
}
});
it("rejects input exceeding max length in regex mode", () => {
const longInput = "a".repeat(100_001);
const result = parseTextToArray(longInput, {
splitMode: "regex",
delimiter: "*",
regexPattern: "\\d",
trimItems: false,
removeEmpty: false,
});
expect(result.items).toEqual([]);
expect(result.error).toContain("Input too long");
});
it("allows input at max length in regex mode", () => {
const input = "a".repeat(100_000);
const result = parseTextToArray(input, {
splitMode: "regex",
delimiter: "*",
regexPattern: "\\d",
trimItems: false,
removeEmpty: false,
});
expect(result.error).toBeNull();
});
it("returns error when regex pattern is too long", () => { it("returns error when regex pattern is too long", () => {
const result = parseTextToArray("a,b,c", { const result = parseTextToArray("a,b,c", {
splitMode: "regex", splitMode: "regex",

23
src/utils/arrayParser.ts

@ -14,6 +14,19 @@ export interface ParseArrayResult {
} }
const MAX_REGEX_PATTERN_LENGTH = 100; const MAX_REGEX_PATTERN_LENGTH = 100;
const MAX_REGEX_INPUT_LENGTH = 100_000;
/**
* Detect regex patterns prone to catastrophic backtracking (ReDoS).
* Rejects nested quantifiers like (a+)+, (a*)+, (a+)*, etc.
*/
function isUnsafePattern(pattern: string): boolean {
// Strip /pattern/flags wrapper to inspect the body
const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i);
const body = slashFormat ? slashFormat[1] : pattern;
// Nested quantifiers: a group containing a quantifier, followed by another quantifier
return /\([^)]*[+*]\)[+*{]/.test(body);
}
function parseRegexPattern(pattern: string): RegExp { function parseRegexPattern(pattern: string): RegExp {
// Supports `/pattern/flags` and plain `pattern`. // Supports `/pattern/flags` and plain `pattern`.
@ -46,6 +59,16 @@ export function parseTextToArray(
items: [], items: [],
error: `Regex pattern too long (max ${MAX_REGEX_PATTERN_LENGTH} characters)`, error: `Regex pattern too long (max ${MAX_REGEX_PATTERN_LENGTH} characters)`,
}; };
} else if (isUnsafePattern(options.regexPattern)) {
return {
items: [],
error: "Regex pattern rejected: nested quantifiers can cause catastrophic backtracking",
};
} else if (source.length > MAX_REGEX_INPUT_LENGTH) {
return {
items: [],
error: `Input too long for regex mode (max ${MAX_REGEX_INPUT_LENGTH.toLocaleString()} characters)`,
};
} else { } else {
rawItems = source.split(parseRegexPattern(options.regexPattern)); rawItems = source.split(parseRegexPattern(options.regexPattern));
} }

Loading…
Cancel
Save