Class RegexGuard

java.lang.Object
groovy.util.regex.RegexGuard

@Incubating public final class RegexGuard extends Object
Deadline-guarded regular expression evaluation, protecting against Regular Expression Denial of Service (ReDoS) when patterns or inputs come from untrusted sources. Java's regex engine has no native timeout, so catastrophic backtracking can hang a thread indefinitely.

The guard works by wrapping the input CharSequence so that charAt periodically checks a deadline and throws RegexTimeoutException once it has passed. Backtracking repeatedly re-reads input characters, so a runaway match is cut off shortly after the deadline without needing watchdog threads or thread interruption.

 import groovy.util.regex.RegexGuard
 import groovy.util.regex.RegexTimeoutException
 import java.time.Duration

 assert RegexGuard.matches(/gro+vy/, 'groovy', 200)
 def m = RegexGuard.matcher(/(\d+)/, 'abc 123', Duration.ofMillis(200))
 assert m.find() && m.group(1) == '123'

 try {
     RegexGuard.matches(/(.*,){16}X/, '1,' * 40, 200) // catastrophic backtracking
     assert false, 'should have timed out'
 } catch (RegexTimeoutException expected) {
 }
 
Notes and limitations:
  • The deadline starts when the guarded matcher is created and covers all subsequent use of it, e.g. repeated find() calls.
  • The clock is only consulted while the engine reads input characters, every 512 reads; evaluations finishing in fewer reads never pay for a clock call. Pathological patterns perform millions of reads per second, so overshoot past the deadline is negligible in practice.
  • Pattern.compile itself is not guarded; pattern compilation is not subject to backtracking.
Since:
6.0.0
See Also:
  • Method Details

    • matches

      public static boolean matches(Object pattern, Object input, long millis)
      Matches the whole input against the pattern like the ==~ operator, giving up once the timeout has elapsed. Follows the operator's conventions: a null pattern or input yields false, non-Pattern patterns and non-String inputs are converted via their default string representation, and the matcher is stored for Matcher.lastMatcher.
      Parameters:
      pattern - the pattern, a Pattern or an object whose string representation is the regex
      input - the text to match
      millis - the timeout in milliseconds (must be positive)
      Returns:
      true if the whole input matches the pattern
      Throws:
      RegexTimeoutException - if evaluation exceeds the timeout
    • matches

      public static boolean matches(Object pattern, Object input, Duration timeout)
      Variant of matches(Object, Object, long) taking the timeout as a Duration.
    • matcher

      public static Matcher matcher(Object pattern, Object input, long millis)
      Creates a matcher of the pattern over deadline-guarded input, like the =~ operator. Matcher operations that read the input, e.g. find() or matches(), throw RegexTimeoutException once the deadline, measured from this call, has passed.
      Parameters:
      pattern - the pattern, a Pattern or an object whose string representation is the regex
      input - the text to match
      millis - the timeout in milliseconds (must be positive)
      Returns:
      a matcher over the guarded input
    • matcher

      public static Matcher matcher(Object pattern, Object input, Duration timeout)
      Variant of matcher(Object, Object, long) taking the timeout as a Duration.
    • matchRegex

      public static boolean matchRegex(Object input, Object pattern, long millis)
      Runtime hook used by the SafeRegex transform: equivalent to matches(Object, Object, long) but with the operands in the ==~ operator's order, so that a rewritten expression still evaluates its operands left to right, exactly as the original did.
      Parameters:
      input - the text to match (the operator's left operand)
      pattern - the pattern (the operator's right operand)
      millis - the timeout in milliseconds (must be positive)
      Returns:
      true if the whole input matches the pattern
      Throws:
      RegexTimeoutException - if evaluation exceeds the timeout
    • findRegex

      public static Matcher findRegex(Object input, Object pattern, long millis)
      Runtime hook used by the SafeRegex transform: equivalent to matcher(Object, Object, long) but with the operands in the =~ operator's order, so that a rewritten expression still evaluates its operands left to right, exactly as the original did.
      Parameters:
      input - the text to match (the operator's left operand)
      pattern - the pattern (the operator's right operand)
      millis - the timeout in milliseconds (must be positive)
      Returns:
      a matcher over the guarded input
    • guard

      public static CharSequence guard(CharSequence input, long millis)
      Wraps a character sequence so that reads beyond the deadline throw RegexTimeoutException. The deadline is attached to the data: it is a single total budget covering every subsequent regex evaluation against the guarded sequence, wherever and on whatever thread it happens. Groovy's regex-evaluating CharSequence extension methods (find, findAll, findGroups, eachMatch, replaceAll, switch/case matching, ...) honour the guard, as does anything that passes the sequence through to java.util.regex untouched, e.g. Pattern.split or manual matcher creation. This complements guard(long, Closure), which instead guards a region of code with a per-evaluation timeout.

      Note the deadline is only consulted while the regex engine reads input characters, so a well-behaved evaluation over a short input may still succeed after the deadline; runaway evaluations are always cut off. A sequence other than a String (e.g. a GString or StringBuilder) is snapshotted via toString() first, so later mutations of the original are not seen by the guarded view.

      Parameters:
      input - the sequence to guard (must not be null)
      millis - the timeout in milliseconds (must be positive)
      Returns:
      a guarded view of the input
    • guard

      public static CharSequence guard(CharSequence input, Duration timeout)
      Variant of guard(CharSequence, long) taking the timeout as a Duration.
    • guard

      public static <T> T guard(long millis, Closure<T> closure)
      Runs the closure with an ambient regex timeout active on the current thread and returns the closure's result. While active, every regex evaluation reached through Groovy's regex operators (==~, =~, and switch/case, grep and in against a Pattern) or the CharSequence extension methods (find, findAll, findGroups, findAllGroups, eachMatch, matches, replaceAll, replaceFirst, splitEachLine, ...) is guarded: each individual evaluation that exceeds the timeout throws RegexTimeoutException.
       import groovy.util.regex.RegexGuard
       import groovy.util.regex.RegexTimeoutException
      
       def groups = RegexGuard.guard(200) {
           '6.0.0-beta-2'.findGroups(/(\d+)\.(\d+)\.(\d+)(?:-(.+))?/)
       }
       assert groups == ['6.0.0-beta-2', '6', '0', '0', 'beta-2']
      
       try {
           RegexGuard.guard(200) {
               ('1,' * 40).findGroups(/(.*,){16}X/) // catastrophic backtracking
           }
           assert false, 'should have timed out'
       } catch (RegexTimeoutException expected) {
       }
       
      Semantics:
      • The timeout applies per evaluation, not to the block as a whole; any number of well-behaved evaluations may run within the scope.
      • The scope is dynamic: regex operations in methods called from the closure are guarded too, as long as they run on the same thread. Threads spawned within the closure are not covered; on JDK 25+, however, the guard does propagate into StructuredTaskScope forks, which by construction run within the enclosing scope.
      • Nested guards only tighten: the smaller per-evaluation timeout wins.
      • An explicit timeout, e.g. from matches(Object, Object, long) or a SafeRegex scope, is likewise capped by the ambient timeout.
      • A matcher created within the scope (e.g. via =~) keeps its deadline, measured from creation, when used after the block exits.
      • Direct java.util.regex calls, e.g. a hand-written pattern.matcher(input), bypass the ambient guard; combine them with guard(CharSequence, long) instead. The same applies to the JDK's own String methods: on a String receiver, matches(String), replaceAll(String, String), replaceFirst(String, String) and split(String) dispatch to java.lang.String and never enter Groovy's runtime. Their Pattern-argument variants, e.g. str.replaceAll(~/expensive/, 'x'), are extension methods and are covered.
      Parameters:
      millis - the per-evaluation timeout in milliseconds (must be positive)
      closure - the code to run under the ambient guard
      Returns:
      the closure's result
    • guard

      public static <T> T guard(Duration timeout, Closure<T> closure)
      Variant of guard(long, Closure) taking the timeout as a Duration.
    • ambientGuard

      public static CharSequence ambientGuard(CharSequence input)
      Runtime hook used by Groovy's regex operators and extension methods: wraps the input so that reads are guarded when an ambient guard (see guard(long, Closure)) is active on the current thread, and returns the input unchanged otherwise.
      Parameters:
      input - the text a regex is about to be evaluated against
      Returns:
      the input, guarded if an ambient guard is active
    • prepare

      public static CharSequence prepare(CharSequence input)
      Runtime hook used by Groovy's regex extension methods: normalizes an input for regex evaluation. A String, or a sequence guarded via guard(CharSequence, long), is passed through, so an attached deadline is honoured by the evaluation; any other sequence (e.g. a GString or StringBuilder) is snapshotted via toString(), as the extension methods have always done. An ambient guard (see guard(long, Closure)), if active, is then applied on top.
      Parameters:
      input - the text a regex is about to be evaluated against
      Returns:
      the normalized, possibly guarded, input