diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 89e700ea..2ae6d4fd 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1714,6 +1714,8 @@ export function WorkflowCanvas() { return "#8b5cf6"; case "prompt": return "#f97316"; + case "array": + return "#a3e635"; case "promptConstructor": return "#f472b6"; case "nanoBanana": diff --git a/src/utils/__tests__/arrayParser.test.ts b/src/utils/__tests__/arrayParser.test.ts index 69f1a6fe..a19be8d9 100644 --- a/src/utils/__tests__/arrayParser.test.ts +++ b/src/utils/__tests__/arrayParser.test.ts @@ -65,6 +65,61 @@ describe("parseTextToArray", () => { 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", () => { const result = parseTextToArray("a,b,c", { splitMode: "regex", diff --git a/src/utils/arrayParser.ts b/src/utils/arrayParser.ts index 4c297e71..8e16ef8c 100644 --- a/src/utils/arrayParser.ts +++ b/src/utils/arrayParser.ts @@ -14,6 +14,19 @@ export interface ParseArrayResult { } 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 { // Supports `/pattern/flags` and plain `pattern`. @@ -46,6 +59,16 @@ export function parseTextToArray( items: [], 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 { rawItems = source.split(parseRegexPattern(options.regexPattern)); }