Skip to content

fix(web_core): mitigate ReDoS vulnerability in regex validation function - #2366

Open
Varun-S10 wants to merge 6 commits into
a2ui-project:mainfrom
Varun-S10:fix/issue-2292
Open

Varun-S10 wants to merge 6 commits into
a2ui-project:mainfrom
Varun-S10:fix/issue-2292

Conversation

@Varun-S10

Copy link
Copy Markdown
Collaborator

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:

  1. Adds Regex Safety Checker (safe_regex.ts):
    • Checks regex patterns before running them.
    • Automatically blocks dangerous patterns that cause infinite loops/freezes.
    • Still allows all normal form patterns (email, phone, dates, zip codes, URLs).
  2. Protects Function Execution (basic_functions.ts):
    • Rejects unsafe regex patterns immediately instead of letting them freeze the browser.
  3. Adds Size Limits (basic_functions_api.ts):
    • Limits regex pattern length to 256 characters and input value to 4096 characters to prevent oversized inputs.
  4. Protects React TextField (TextField.tsx):
    • Checks the regex safely before testing user input.
  5. Adds Tests:
    • Added unit tests to make sure dangerous patterns are blocked and standard patterns work properly.

Results:

  • Dangerous Patterns: Blocked instantly in less than 0.2ms (no more browser freezes).
  • Normal Patterns: Email, phone, date, and text validations continue working 100% as expected.

Fixes #2292

Pre-launch Checklist

One time:

For this PR:

  • I have updated the relevant CHANGELOG.md file.
  • I updated/added relevant documentation.
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes.

If you need help, consider asking for advice on the discussion board.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +228 to +241
} 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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

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);
      }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the AST parser to parse . as an EscapeNode with value '.' so it is properly recognized as a wildcard.

Comment on lines +371 to +376
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Update nodeMatchesChar to treat the parsed wildcard . as matching any character.

Suggested change
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;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Update branchesOverlap to ensure that any branch starting with a wildcard . is considered to overlap with any other branch.

  if (s1.any || s2.any) return true;
  if (s1.escape === '.' || s2.escape === '.') return true;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. Added the check so any branch with a wildcard . is marked as overlapping.

Comment on lines +278 to +286
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,
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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;
      }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added index backtracking (i = start) when the quantifier range is invalid or when the closing brace } is missing.

Comment on lines +355 to +361
for (const el of branch.elements) {
if (el !== first && el !== last && 'quantifier' in el && el.quantifier) {
if (nodeMatchesChar(el, sep)) {
return false;
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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;
        }
      }
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the loop to only exclude the actual separator element sepEl, ensuring non-disjoint repeated patterns like (aa+)* are properly checked and rejected.

@Varun-S10

Copy link
Copy Markdown
Collaborator Author

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Unbounded agent-supplied regex executed on the client main thread (regex validation function)

2 participants