Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a static analysis mechanism (isSafeRegex) to protect against catastrophic backtracking (ReDoS) vulnerabilities in regular expression patterns, integrating it into the React TextField component and the core regex validation function. The code reviewer identified several critical security issues and logic bugs in the newly added safe_regex.ts file. These include a failure to parse the wildcard character . correctly (treating it as a literal), a parser backtracking bug in parseQuantifier when handling invalid quantifiers, and a logic error in hasDisjointSeparator that incorrectly excludes elements from safety checks, potentially allowing unsafe patterns to bypass validation.
| } else if (char === '^' || char === '$') { | ||
| i++; | ||
| elements.push({type: 'Anchor', value: char}); | ||
| } else { | ||
| const lit = pattern[i]; | ||
| i++; | ||
| const litNode: LiteralNode = { | ||
| type: 'Literal', | ||
| value: lit, | ||
| quantifier: null, | ||
| }; | ||
| parseQuantifier(litNode); | ||
| elements.push(litNode); | ||
| } |
There was a problem hiding this comment.
The wildcard character . is currently parsed as a literal character . instead of a wildcard matching any character. This leads to severe false negatives where dangerous patterns like (a|.)+ are incorrectly classified as safe.
We should parse . as a special EscapeNode with value '.' so that it can be handled as a wildcard in subsequent safety checks.
} else if (char === '^' || char === '$') {
i++;
elements.push({type: 'Anchor', value: char});
} else if (char === '.') {
i++;
const dotNode: EscapeNode = {
type: 'Escape',
value: '.',
quantifier: null,
};
parseQuantifier(dotNode);
elements.push(dotNode);
} else {
const lit = pattern[i];
i++;
const litNode: LiteralNode = {
type: 'Literal',
value: lit,
quantifier: null,
};
parseQuantifier(litNode);
elements.push(litNode);
}There was a problem hiding this comment.
Updated the AST parser to parse . as an EscapeNode with value '.' so it is properly recognized as a wildcard.
| if (node.type === 'Escape') { | ||
| if (node.value === 'd' && /^[0-9]$/.test(char)) return true; | ||
| if (node.value === 'w' && /^[a-zA-Z0-9_]$/.test(char)) return true; | ||
| if (node.value === 's' && /^\s$/.test(char)) return true; | ||
| return node.value === char; | ||
| } |
There was a problem hiding this comment.
Update nodeMatchesChar to treat the parsed wildcard . as matching any character.
| if (node.type === 'Escape') { | |
| if (node.value === 'd' && /^[0-9]$/.test(char)) return true; | |
| if (node.value === 'w' && /^[a-zA-Z0-9_]$/.test(char)) return true; | |
| if (node.value === 's' && /^\s$/.test(char)) return true; | |
| return node.value === char; | |
| } | |
| if (node.type === 'Escape') { | |
| if (node.value === '.') return true; | |
| if (node.value === 'd' && /^[0-9]$/.test(char)) return true; | |
| if (node.value === 'w' && /^[a-zA-Z0-9_]$/.test(char)) return true; | |
| if (node.value === 's' && /^\\s$/.test(char)) return true; | |
| return node.value === char; | |
| } |
There was a problem hiding this comment.
Updated nodeMatchesChar to treat node.value === '.' as matching any character.
| const s1 = getFirstCharSummary(b1); | ||
| const s2 = getFirstCharSummary(b2); | ||
|
|
||
| if (s1.any || s2.any) return true; |
There was a problem hiding this comment.
Applied. Added the check so any branch with a wildcard . is marked as overlapping.
| if (i < pattern.length && pattern[i] === '}') { | ||
| i++; | ||
| quant = { | ||
| raw: pattern.slice(start, i), | ||
| min: isNaN(min) ? 1 : min, | ||
| max: isNaN(max) ? Infinity : max, | ||
| lazy: false, | ||
| }; | ||
| } |
There was a problem hiding this comment.
In parseQuantifier, if the closing brace } is missing or if the quantifier is invalid (e.g., {invalid}), the parser does not backtrack i to start. This causes the parser to skip those characters entirely from being parsed as literals, and can even incorrectly treat invalid patterns as valid quantifiers.
We should validate that both min and max are valid numbers, and if not (or if the closing } is missing), backtrack i to start so the characters can be parsed as literals.
if (i < pattern.length && pattern[i] === '}') {
i++;
if (!isNaN(min) && !isNaN(max)) {
quant = {
raw: pattern.slice(start, i),
min,
max,
lazy: false,
};
} else {
i = start;
}
} else {
i = start;
}There was a problem hiding this comment.
Fixed. Added index backtracking (i = start) when the quantifier range is invalid or when the closing brace } is missing.
| for (const el of branch.elements) { | ||
| if (el !== first && el !== last && 'quantifier' in el && el.quantifier) { | ||
| if (nodeMatchesChar(el, sep)) { | ||
| return false; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
In hasDisjointSeparator, the loop excludes both first and last elements from being checked against the separator:
if (el !== first && el !== last && ...)However, if first is the separator (e.g., in ,[a-z]+), then last is the quantified element and MUST be checked to ensure it does not match the separator. Excluding last from the check allows unsafe patterns like (aa+)* to bypass the ReDoS safety check.
We should only exclude the actual separator element (sepEl) from the check.
const sepEl = firstIsSep ? first : last;
for (const el of branch.elements) {
if (el !== sepEl && 'quantifier' in el && el.quantifier) {
if (nodeMatchesChar(el, sep)) {
return false;
}
}
}There was a problem hiding this comment.
Updated the loop to only exclude the actual separator element sepEl, ensuring non-disjoint repeated patterns like (aa+)* are properly checked and rejected.
4ff6d1c to
06287e4
Compare
|
Hi @gspencergoog, could you please review this PR? It fixes #2292 by adding safe regex validation to prevent ReDoS freezes and UI lockups. I have also merged the latest changes from main. |
| * @param options Configuration options including maximum pattern length. | ||
| * @returns `true` if the regex pattern is safe to execute on the client main thread; `false` otherwise. | ||
| */ | ||
| export function isSafeRegex(pattern: string, options?: SafeRegexOptions): boolean { |
There was a problem hiding this comment.
This is a lot of code to maintain. Are you sure that a regex validator doesn't already exist?
For example: https://github.com/tjenkinson/redos-detector
There was a problem hiding this comment.
Thank you for the suggestion, @gspencergoog. I have removed the custom regex parser in safe_regex.ts (~580 lines) and replaced it with redos-detector (isSafePattern). All existing unit tests for safe and unsafe ReDoS patterns are passing.
Description
This PR fixes a bug where bad regular expression patterns would freeze and crash the browser (ReDoS issue).
Why this change is needed:
When a user types into an input field (like
TextField), A2UI runs regex validation on every keystroke. Previously, if a regex pattern had nested repetitions (like(a+)+b), the browser's JavaScript engine would get stuck in an endless loop trying to match the text. This blocked the main thread and caused the entire web page to freeze.What this PR does:
safe_regex.ts):basic_functions.ts):basic_functions_api.ts):TextField.tsx):Results:
Fixes #2292
Pre-launch Checklist
One time:
For this PR:
If you need help, consider asking for advice on the discussion board.