@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:
find() calls.Pattern.compile itself is not guarded; pattern compilation is
not subject to backtracking.| Type Params | Return Type | Name and description |
|---|---|---|
|
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. |
|
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. |
|
public static CharSequence |
guard(CharSequence input, long millis)Wraps a character sequence so that reads beyond the deadline throw RegexTimeoutException. |
|
public static CharSequence |
guard(CharSequence input, Duration timeout)Variant of guard(CharSequence, long) taking the timeout as a Duration. |
<T> |
public static 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. |
<T> |
public static T |
guard(Duration timeout, Closure<T> closure)Variant of guard(long, Closure) taking the timeout as a Duration. |
|
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. |
|
public static Matcher |
matcher(Object pattern, Object input, long millis)Creates a matcher of the pattern over deadline-guarded input, like the =~ operator. |
|
public static Matcher |
matcher(Object pattern, Object input, Duration timeout)Variant of matcher(Object, Object, long) taking the timeout as a Duration. |
|
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. |
|
public static boolean |
matches(Object pattern, Object input, Duration timeout)Variant of matches(Object, Object, long) taking the timeout as a Duration. |
|
public static CharSequence |
prepare(CharSequence input)Runtime hook used by Groovy's regex extension methods: normalizes an input for regex evaluation. |
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.
input - the text a regex is about to be evaluated against 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.
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) 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.
input - the sequence to guard (must not be null)millis - the timeout in milliseconds (must be positive)Variant of guard(CharSequence, long) taking the timeout as a Duration.
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:
StructuredTaskScope forks, which by construction run
within the enclosing scope.=~) keeps its deadline,
measured from creation, when used after the block exits.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.millis - the per-evaluation timeout in milliseconds (must be positive)closure - the code to run under the ambient guardVariant of guard(long, Closure) taking the timeout as a Duration.
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.
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)true if the whole input matches the pattern 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.
pattern - the pattern, a Pattern or an object whose string representation is the regexinput - the text to matchmillis - the timeout in milliseconds (must be positive)Variant of matcher(Object, Object, long) taking the timeout as a Duration.
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.
pattern - the pattern, a Pattern or an object whose string representation is the regexinput - the text to matchmillis - the timeout in milliseconds (must be positive)true if the whole input matches the patternVariant of matches(Object, Object, long) taking the timeout as a Duration.
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.
input - the text a regex is about to be evaluated against