[
https://issues.apache.org/jira/browse/GROOVY-12123?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King updated GROOVY-12123:
-------------------------------
Description:
h2. Summary
Add an opt-in, thread-free runtime guard against Regular Expression Denial of
Service
(ReDoS / catastrophic backtracking) for Groovy's regex features, offered as a
runtime
helper ({{RegexGuard}}), a scoped annotation ({{@SafeRegex}}), and type-checker
awareness
of both. Everything is opt-in; no existing code changes behaviour.
h2. Motivation
Groovy makes regex frictionless -- {{=~}}, {{==~}}, {{~/.../}},
{{String#matches}},
{{replaceAll}}, {{split}}, and the {{Matcher}} sugar -- which also makes ReDoS
easy to
reach when a pattern (or its input) comes from outside the program: scripts,
build files,
web handlers, {{ConfigSlurper}}, templating.
{{java.util.regex}} has *no native timeout*, so a crafted input against a
backtracking-prone pattern can hang the calling thread indefinitely. Nothing in
Groovy
guards against this today. Note the trust boundary is usually the *input*, not
the
pattern: the textbook ReDoS applies a hardcoded, developer-written validator to
attacker-supplied text, so "don't run untrusted code" is not a sufficient
answer.
Thread interruption does not help: the regex engine never checks the interrupt
flag, so
neither {{@ThreadInterrupt}} nor {{@TimedInterrupt}} can stop a runaway match
-- the hang
is inside a single JDK library call, not in user code where those transforms
insert checks.
JLine 4.3.1 shipped a {{SafeRegex}} utility using the well-known
{{TimeoutCharSequence}}
technique to close four CVEs (GHSA-r2xf-8xr9-62gw, GHSA-2v9w-34q6-wpqx,
GHSA-ph9c-7hw9-vhhw, GHSA-5q95-hrpc-m3w3). We apply the same idea.
h2. Proposal
Bound regex matching by a wall-clock deadline, without a watchdog thread. The
input
{{CharSequence}} is wrapped so that {{charAt()}} checks a deadline and throws
an unchecked
{{RegexTimeoutException}} once it has passed; because the regex engine re-reads
input
characters continuously while backtracking, a runaway match is interrupted with
no extra
thread and no {{Thread.interrupt}} plumbing. The clock is consulted once every
512 reads,
so evaluations finishing in fewer reads never pay for a clock call.
Four ways to apply it, from most explicit to most ergonomic.
*(a) Explicit guarded evaluation* -- guarded equivalents of the match and find
operators,
with a {{long}} millisecond or {{Duration}} timeout:
{code:groovy}
RegexGuard.matches(pattern, input, 200) // -> boolean
RegexGuard.matcher(pattern, input, Duration.ofMillis(200)) // -> Matcher
over guarded input
{code}
*(b) Guarded data* -- attach a single total deadline to the sequence itself.
Wrap
untrusted input once at a trust boundary and pass it around freely; every regex
evaluated
against it shares the one budget, including Groovy's {{CharSequence}} extension
methods
and anything handing the sequence to {{java.util.regex}} untouched:
{code:groovy}
def safe = RegexGuard.guard(untrusted, 200)
safe.findAll(~/\d+/)
{code}
*(c) Ambient scope* -- a per-evaluation timeout for the dynamic extent of a
closure on the
current thread, covering the regex operators and the {{CharSequence}} extension
methods,
including in methods called from the closure:
{code:groovy}
def groups = RegexGuard.guard(200) {
input.findGroups(/(\d+)\.(\d+)\.(\d+)/)
}
{code}
Nested scopes only tighten -- the smaller per-evaluation timeout wins -- and an
explicit
timeout from (a) is likewise capped by an active ambient timeout.
*(d) Scoped annotation ({{@SafeRegex}})* -- an AST transform rewrites the match
({{==~}})
and find ({{=~}}) operators lexically within the annotated scope to guarded
calls, so no
call-site changes are needed. Fits the existing {{@TimedInterrupt}} /
{{@ThreadInterrupt}}
/ {{@ConditionalInterrupt}} family in {{groovy.transform}}:
{code:groovy}
@SafeRegex(millis = 200)
class Handler {
boolean check(String input) {
input ==~ /(a+)+$/ // rewritten -> RegexGuard.matchRegex(input,
/(a+)+$/, 200)
}
}
{code}
The annotation may be placed on a class, method, constructor, field or local
variable
declaration; on a field or local variable only the initializer expression is
guarded. Its
single attribute is {{millis}} (default {{1000}}, must be a positive constant).
A guarded
field initializer runs, as usual, in the constructor -- or in the static
initializer for a
static field, where a timeout surfaces as {{ExceptionInInitializerError}} whose
cause is
the {{RegexTimeoutException}}.
The transform targets the operator-order entry points {{matchRegex(input,
pattern,
millis)}} and {{findRegex(input, pattern, millis)}} rather than
{{matches(pattern, input, millis)}}, so a rewritten expression still evaluates
its
operands left to right exactly as the original operator did.
The guard is on *matching*, not on {{~/.../}} compilation: {{Pattern.compile}}
is not
subject to backtracking and is not guarded.
h3. Naming
|| Concern || Name || Notes ||
| Scoped annotation | {{@SafeRegex}} | in {{groovy.transform}}, alongside
{{@TimedInterrupt}} et al. |
| Runtime helper | {{RegexGuard}} | in {{groovy.util.regex}}; distinct from the
annotation to avoid a clash |
| Timeout exception | {{RegexTimeoutException}} | unchecked; lets callers
distinguish a ReDoS abort from a normal non-match |
| Operator-order entry points | {{matchRegex}} / {{findRegex}} | mirror
{{ScriptBytecodeAdapter}} naming and operand order; used by the transform |
Both {{RegexGuard}} and {{@SafeRegex}} are marked {{@Incubating}}, {{@since
6.0.0}}.
h3. Implementation notes
* The ambient scope in (c) is held in an
{{org.apache.groovy.runtime.async.ScopedLocal}},
which delegates to {{java.lang.ScopedValue}} on JDK 25+ and falls back to a
save-and-restore {{ThreadLocal}} below, so the binding cannot outlive its
closure.
* A process that never installs an ambient guard never touches that scoped
local: a
monotonic static flag short-circuits the lookup, leaving a single cached read
on the
regex path.
* Deadlines are only ever compared as {{System.nanoTime()}} differences, which
stays
correct across wrap-around; a timeout large enough to overflow the deadline
therefore
reads as "effectively no timeout" rather than expiring immediately.
h2. Scope
*In scope*
* Runtime deadline guard ({{TimeoutCharSequence}}-style), thread-free
* {{RegexGuard}} helper: explicit guarded evaluation, guarded data, and ambient
closure scope
* {{@SafeRegex}} scoped annotation rewriting the {{==~}} and {{=~}} operators
* {{RegexTimeoutException}} type
* {{RegexChecker}} (groovy-typecheckers) awareness of guarded evaluation, so
code inside a
{{@SafeRegex}} scope keeps full regex checking after the rewrite
* Documentation in the operator, metaprogramming and type-checker guides
*Out of scope / deferred*
* *Internal hardening of Groovy's own regex call sites* ({{groovysh}}
completion,
templating, builders) -- proposed originally, not implemented here; deferred
to a
follow-up so this ticket stays a purely opt-in, additive change
* Making the guard the default for {{=~}} / {{==~}} (behaviour change +
overhead)
* Rewriting the {{String#matches}} / {{replaceAll}} / {{split}} family of
method calls
within a {{@SafeRegex}} scope -- only the two operators are rewritten; use
{{RegexGuard}} explicitly for the rest
* Static ReDoS pattern detection -- a possible future {{RegexChecker}} extension
* Guarding {{Pattern.compile}}
* Rewriting the regex engine or adopting a non-backtracking (RE2-style) engine
h2. Limitations
* {{@SafeRegex}} rewrites only the *lexically visible* {{==~}} and {{=~}}
operators --
scoped hardening, not whole-program. Regex evaluation via method calls, or
occurring in
code called from the annotated scope, is not rewritten; use {{RegexGuard}}
there, whose
ambient and guarded-data forms do reach both.
* Direct JDK calls bypass the guard. 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 are extension methods and are covered.
* The deadline fires only while the engine is reading input characters (the
backtracking
phase) -- true of catastrophic cases, but not a hard guarantee for every
pathological
state. A well-behaved evaluation over a short input may still succeed after
the deadline.
* The clock is consulted every 512 character reads rather than on every read,
so overshoot
past the deadline is bounded by those reads; pathological patterns perform
millions of
reads per second, so in practice the overshoot is negligible.
* The ambient scope is thread-confined: threads spawned within the closure are
not covered
(on JDK 25+ the binding does propagate into {{StructuredTaskScope}} forks).
* Reduces hang risk; it is not a correctness or a data-at-rest control.
h2. Relationship to existing/planned features
|| Layer || Mechanism || Status || Covers ReDoS? ||
| Compile-time validity | {{RegexChecker}} (groovy-typecheckers) | ships | No
-- pattern *syntax* only |
| Compile-time ReDoS lint | possible {{RegexChecker}} extension | idea |
Partially |
| Untrusted-input tracking | GEP-25 {{@Tainted}} -> regex sink | draft |
Identifies risky matches |
| *Runtime guard (this ticket)* | {{RegexGuard}} / {{@SafeRegex}} |
*implemented* | *Yes -- the actual backstop* |
{{RegexChecker}} only validates that a literal pattern compiles; it says
nothing about
backtracking. As part of this ticket it was extended to recognise the guarded
forms, so
the rewrite performed by {{@SafeRegex}} does not blind it: bad regex literals
are still
reported, and matcher group-count inference survives the rewrite.
h2. Ordering with GROOVY-12123
GROOVY-12123 (constant regex hoisting) and this transform touch the same
expressions, so
whichever lands second must be ordering-aware:
* When {{@SafeRegex}} rewrites {{input ==~ /(a+)+$/}} to
{{RegexGuard.matchRegex(input, /(a+)+$/, 200)}}, the hoisting pass must still
recognise
the pattern -- now the *second* argument, not the first -- as a hoistable
constant and
lift it to a {{static final}} field.
* When hoisting runs first and produces a {{static final Pattern P}},
{{@SafeRegex}} must
still recognise a match against {{P}} and wrap the *input* in the deadline
guard.
Desired combined result: the constant {{Pattern}} is compiled once into a
{{static final}}
field *and* each match wraps the input in the deadline guard.
h2. Licensing / reuse
JLine is BSD-3 (ASF category A) and already on the {{groovysh}} classpath
(jline-builtins/jansi), so {{groovysh}} may use JLine's {{SafeRegex}} directly.
For *core*
Groovy the technique is ~20 lines and well-known, so it is reimplemented from
the
technique rather than taking a core dependency on JLine. No code was copied.
h2. References
* JLine 4.3.1 release notes -- SafeRegex / TimeoutCharSequence:
https://github.com/jline/jline3/releases/tag/4.3.1
* CWE-1333: Inefficient Regular Expression Complexity:
https://cwe.mitre.org/data/definitions/1333.html
* OWASP -- Regular expression Denial of Service (ReDoS):
https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
* Related: GEP-25 (Data-flow Security -- taint tracking); {{RegexChecker}} in
groovy-typecheckers; {{@TimedInterrupt}} / {{@ThreadInterrupt}} /
{{@ConditionalInterrupt}} in groovy.transform
* Relates to: GROOVY-12123 (constant regex hoisting -- compile-once static
final Pattern fields): https://issues.apache.org/jira/browse/GROOVY-12123
* Pull request: https://github.com/apache/groovy/pull/2668
was:
h2. Summary
Hoist effectively-constant regular expressions to synthetic {{private static
final Pattern}}
fields so they are compiled once, rather than recompiled on every evaluation --
most
importantly inside loops. Transparent optimization; no new user-facing syntax.
h2. Motivation
Groovy's {{~/.../}} operator (and the {{=~}} / {{==~}} operators and JDK
conveniences such
as {{String#matches}}) construct/compile a {{Pattern}} each time they are
evaluated. In a
loop, a constant pattern is recompiled every iteration:
{code:groovy}
for (line in lines) {
if (line ==~ /\d{4}-\d{2}-\d{2}/) { ... } // recompiled every iteration
today
}
{code}
{{Pattern.compile}} is not cheap, and the pattern here never changes. Lifting
it to a
compile-once {{static final}} field removes the repeated compilation with no
change in
observable behaviour.
h2. Proposal
Where the compiler can prove a pattern is *effectively constant*, replace the
inline
regex expression with a reference to a synthetic {{private static final
Pattern}} field on
the enclosing class, initialized once. Identical (source + flags) patterns in
the same
class share a single field.
{code:groovy}
// conceptually, the loop above becomes:
private static final Pattern $pat$0 = Pattern.compile('\\d{4}-\\d{2}-\\d{2}')
...
for (line in lines) {
if ($pat$0.matcher(line).matches()) { ... }
}
{code}
This is loop-invariant code motion specialized for regex construction. It is
applied
automatically by the compiler when safe (see _Applicability_ and
_Correctness_), not via
an annotation.
h2. Applicability ("was applicable")
Only patterns with no runtime-dependent parts qualify:
* {{~/literal/}} with no interpolation -- hoistable.
* {{Pattern.compile("literal"[, constantFlags])}} -- hoistable (method-call
form).
* {{~"text ${CONST}"}} where the interpolated hole constant-folds -- hoistable
after folding.
* {{~"text ${var}"}} with a runtime {{var}} -- *not* hoistable; must build each
time.
* Any pattern closing over a captured, non-constant local -- *not* hoistable.
The primary targets are the {{~/.../}} operator and {{Pattern.compile}}.
Rewriting the JDK
convenience methods ({{String#matches}}/{{replaceAll}}/{{split}} with a
constant arg) into a
hoisted-{{Pattern}} form is a larger semantic change (routing through
{{Pattern}}/{{Matcher}})
and is a possible follow-up extension, not the initial cut.
h2. Correctness / caveats
* *Thread-safety is fine.* {{Pattern}} is immutable and thread-safe, so a shared
{{static final}} field is safe. {{Matcher}} is stateful and is *never*
hoisted -- only the
{{Pattern}}, with a fresh {{Matcher}} per match as today.
* *Exception timing.* An *invalid* constant pattern currently throws
{{PatternSyntaxException}}
at the point of evaluation (possibly never, if guarded by a branch). Eager
static-field
init would move that to class initialization
({{ExceptionInInitializerError}}). Mitigation:
only hoist patterns already known valid (the {{RegexChecker}} type checker
validates constant
literals), and/or use a lazy holder so init timing is preserved.
* *Identity.* User code must not rely on each {{~/.../}} yielding a distinct
{{Pattern}}
instance ({{===}}); {{Pattern}} carries no meaningful identity or mutability
contract, so
sharing is observationally equivalent.
h2. Scope
*In scope*
* Hoisting constant {{~/.../}} and {{Pattern.compile(constant[, constFlags])}}
to synthetic
{{static final}} fields, with per-class deduplication of identical patterns.
* Applying inside loops, closures, and script bodies (field on the enclosing
class).
*Out of scope / deferred*
* Rewriting {{String#matches}}/{{replaceAll}}/{{split}} convenience methods
(follow-up).
* Hoisting non-constant patterns or any form of runtime {{Pattern}} cache.
* Cross-class / cross-method caching.
h2. Relates to / must compose with GROOVY-12122
GROOVY-12122 adds a ReDoS runtime guard ({{RegexGuard}} helper, {{@SafeRegex}}
scoped
annotation). The two touch the same regex AST surface and sit on opposite ends
of the same
operation -- this ticket optimizes *Pattern construction*, GROOVY-12122 guards
*matching* --
so they compose, but the transforms must be ordering-aware:
* When {{@SafeRegex}} rewrites {{input ==~ /(a+)+$/}} to
{{RegexGuard.matches(~/(a+)+$/, input, ...)}}, this optimization must still
recognize the
{{~/(a+)+$/}} argument as a hoistable constant and lift it.
* When this optimization runs first and produces a {{static final Pattern P}},
{{@SafeRegex}}
must still recognize a match against {{P}} and wrap the input.
Desired combined result: the constant {{Pattern}} is compiled once into a
{{static final}}
field *and* each match wraps the input in the deadline guard. Whoever
implements the second
of the two should account for the first.
h2. Implementation notes
* A compiler optimization pass / AST transform over resolved expressions; runs
in both
dynamic and {{@CompileStatic}} modes.
* Constant detection reuses the same effectively-constant analysis as constant
folding.
* Synthetic field naming should be collision-free and deduplicated per class.
* Consider gating behind a flag initially (or restricting to
{{RegexChecker}}-validated
patterns) until the exception-timing behaviour is settled.
h2. References
* Related: GROOVY-12122 (ReDoS runtime guard -- RegexGuard / @SafeRegex):
https://issues.apache.org/jira/browse/GROOVY-12122
* {{java.util.regex.Pattern}} -- immutable/thread-safe; {{Matcher}} -- not
thread-safe
* {{RegexChecker}} in groovy-typecheckers (compile-time constant-pattern
validation)
> Hoist constant regexes to static final fields
> ---------------------------------------------
>
> Key: GROOVY-12123
> URL: https://issues.apache.org/jira/browse/GROOVY-12123
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Priority: Major
> Fix For: 7.x
>
>
> h2. Summary
> Add an opt-in, thread-free runtime guard against Regular Expression Denial of
> Service
> (ReDoS / catastrophic backtracking) for Groovy's regex features, offered as a
> runtime
> helper ({{RegexGuard}}), a scoped annotation ({{@SafeRegex}}), and
> type-checker awareness
> of both. Everything is opt-in; no existing code changes behaviour.
> h2. Motivation
> Groovy makes regex frictionless -- {{=~}}, {{==~}}, {{~/.../}},
> {{String#matches}},
> {{replaceAll}}, {{split}}, and the {{Matcher}} sugar -- which also makes
> ReDoS easy to
> reach when a pattern (or its input) comes from outside the program: scripts,
> build files,
> web handlers, {{ConfigSlurper}}, templating.
> {{java.util.regex}} has *no native timeout*, so a crafted input against a
> backtracking-prone pattern can hang the calling thread indefinitely. Nothing
> in Groovy
> guards against this today. Note the trust boundary is usually the *input*,
> not the
> pattern: the textbook ReDoS applies a hardcoded, developer-written validator
> to
> attacker-supplied text, so "don't run untrusted code" is not a sufficient
> answer.
> Thread interruption does not help: the regex engine never checks the
> interrupt flag, so
> neither {{@ThreadInterrupt}} nor {{@TimedInterrupt}} can stop a runaway match
> -- the hang
> is inside a single JDK library call, not in user code where those transforms
> insert checks.
> JLine 4.3.1 shipped a {{SafeRegex}} utility using the well-known
> {{TimeoutCharSequence}}
> technique to close four CVEs (GHSA-r2xf-8xr9-62gw, GHSA-2v9w-34q6-wpqx,
> GHSA-ph9c-7hw9-vhhw, GHSA-5q95-hrpc-m3w3). We apply the same idea.
> h2. Proposal
> Bound regex matching by a wall-clock deadline, without a watchdog thread. The
> input
> {{CharSequence}} is wrapped so that {{charAt()}} checks a deadline and throws
> an unchecked
> {{RegexTimeoutException}} once it has passed; because the regex engine
> re-reads input
> characters continuously while backtracking, a runaway match is interrupted
> with no extra
> thread and no {{Thread.interrupt}} plumbing. The clock is consulted once
> every 512 reads,
> so evaluations finishing in fewer reads never pay for a clock call.
> Four ways to apply it, from most explicit to most ergonomic.
> *(a) Explicit guarded evaluation* -- guarded equivalents of the match and
> find operators,
> with a {{long}} millisecond or {{Duration}} timeout:
> {code:groovy}
> RegexGuard.matches(pattern, input, 200) // -> boolean
> RegexGuard.matcher(pattern, input, Duration.ofMillis(200)) // -> Matcher
> over guarded input
> {code}
> *(b) Guarded data* -- attach a single total deadline to the sequence itself.
> Wrap
> untrusted input once at a trust boundary and pass it around freely; every
> regex evaluated
> against it shares the one budget, including Groovy's {{CharSequence}}
> extension methods
> and anything handing the sequence to {{java.util.regex}} untouched:
> {code:groovy}
> def safe = RegexGuard.guard(untrusted, 200)
> safe.findAll(~/\d+/)
> {code}
> *(c) Ambient scope* -- a per-evaluation timeout for the dynamic extent of a
> closure on the
> current thread, covering the regex operators and the {{CharSequence}}
> extension methods,
> including in methods called from the closure:
> {code:groovy}
> def groups = RegexGuard.guard(200) {
> input.findGroups(/(\d+)\.(\d+)\.(\d+)/)
> }
> {code}
> Nested scopes only tighten -- the smaller per-evaluation timeout wins -- and
> an explicit
> timeout from (a) is likewise capped by an active ambient timeout.
> *(d) Scoped annotation ({{@SafeRegex}})* -- an AST transform rewrites the
> match ({{==~}})
> and find ({{=~}}) operators lexically within the annotated scope to guarded
> calls, so no
> call-site changes are needed. Fits the existing {{@TimedInterrupt}} /
> {{@ThreadInterrupt}}
> / {{@ConditionalInterrupt}} family in {{groovy.transform}}:
> {code:groovy}
> @SafeRegex(millis = 200)
> class Handler {
> boolean check(String input) {
> input ==~ /(a+)+$/ // rewritten -> RegexGuard.matchRegex(input,
> /(a+)+$/, 200)
> }
> }
> {code}
> The annotation may be placed on a class, method, constructor, field or local
> variable
> declaration; on a field or local variable only the initializer expression is
> guarded. Its
> single attribute is {{millis}} (default {{1000}}, must be a positive
> constant). A guarded
> field initializer runs, as usual, in the constructor -- or in the static
> initializer for a
> static field, where a timeout surfaces as {{ExceptionInInitializerError}}
> whose cause is
> the {{RegexTimeoutException}}.
> The transform targets the operator-order entry points {{matchRegex(input,
> pattern,
> millis)}} and {{findRegex(input, pattern, millis)}} rather than
> {{matches(pattern, input, millis)}}, so a rewritten expression still
> evaluates its
> operands left to right exactly as the original operator did.
> The guard is on *matching*, not on {{~/.../}} compilation:
> {{Pattern.compile}} is not
> subject to backtracking and is not guarded.
> h3. Naming
> || Concern || Name || Notes ||
> | Scoped annotation | {{@SafeRegex}} | in {{groovy.transform}}, alongside
> {{@TimedInterrupt}} et al. |
> | Runtime helper | {{RegexGuard}} | in {{groovy.util.regex}}; distinct from
> the annotation to avoid a clash |
> | Timeout exception | {{RegexTimeoutException}} | unchecked; lets callers
> distinguish a ReDoS abort from a normal non-match |
> | Operator-order entry points | {{matchRegex}} / {{findRegex}} | mirror
> {{ScriptBytecodeAdapter}} naming and operand order; used by the transform |
> Both {{RegexGuard}} and {{@SafeRegex}} are marked {{@Incubating}}, {{@since
> 6.0.0}}.
> h3. Implementation notes
> * The ambient scope in (c) is held in an
> {{org.apache.groovy.runtime.async.ScopedLocal}},
> which delegates to {{java.lang.ScopedValue}} on JDK 25+ and falls back to a
> save-and-restore {{ThreadLocal}} below, so the binding cannot outlive its
> closure.
> * A process that never installs an ambient guard never touches that scoped
> local: a
> monotonic static flag short-circuits the lookup, leaving a single cached
> read on the
> regex path.
> * Deadlines are only ever compared as {{System.nanoTime()}} differences,
> which stays
> correct across wrap-around; a timeout large enough to overflow the deadline
> therefore
> reads as "effectively no timeout" rather than expiring immediately.
> h2. Scope
> *In scope*
> * Runtime deadline guard ({{TimeoutCharSequence}}-style), thread-free
> * {{RegexGuard}} helper: explicit guarded evaluation, guarded data, and
> ambient closure scope
> * {{@SafeRegex}} scoped annotation rewriting the {{==~}} and {{=~}} operators
> * {{RegexTimeoutException}} type
> * {{RegexChecker}} (groovy-typecheckers) awareness of guarded evaluation, so
> code inside a
> {{@SafeRegex}} scope keeps full regex checking after the rewrite
> * Documentation in the operator, metaprogramming and type-checker guides
> *Out of scope / deferred*
> * *Internal hardening of Groovy's own regex call sites* ({{groovysh}}
> completion,
> templating, builders) -- proposed originally, not implemented here;
> deferred to a
> follow-up so this ticket stays a purely opt-in, additive change
> * Making the guard the default for {{=~}} / {{==~}} (behaviour change +
> overhead)
> * Rewriting the {{String#matches}} / {{replaceAll}} / {{split}} family of
> method calls
> within a {{@SafeRegex}} scope -- only the two operators are rewritten; use
> {{RegexGuard}} explicitly for the rest
> * Static ReDoS pattern detection -- a possible future {{RegexChecker}}
> extension
> * Guarding {{Pattern.compile}}
> * Rewriting the regex engine or adopting a non-backtracking (RE2-style) engine
> h2. Limitations
> * {{@SafeRegex}} rewrites only the *lexically visible* {{==~}} and {{=~}}
> operators --
> scoped hardening, not whole-program. Regex evaluation via method calls, or
> occurring in
> code called from the annotated scope, is not rewritten; use {{RegexGuard}}
> there, whose
> ambient and guarded-data forms do reach both.
> * Direct JDK calls bypass the guard. 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 are extension methods and are covered.
> * The deadline fires only while the engine is reading input characters (the
> backtracking
> phase) -- true of catastrophic cases, but not a hard guarantee for every
> pathological
> state. A well-behaved evaluation over a short input may still succeed after
> the deadline.
> * The clock is consulted every 512 character reads rather than on every read,
> so overshoot
> past the deadline is bounded by those reads; pathological patterns perform
> millions of
> reads per second, so in practice the overshoot is negligible.
> * The ambient scope is thread-confined: threads spawned within the closure
> are not covered
> (on JDK 25+ the binding does propagate into {{StructuredTaskScope}} forks).
> * Reduces hang risk; it is not a correctness or a data-at-rest control.
> h2. Relationship to existing/planned features
> || Layer || Mechanism || Status || Covers ReDoS? ||
> | Compile-time validity | {{RegexChecker}} (groovy-typecheckers) | ships | No
> -- pattern *syntax* only |
> | Compile-time ReDoS lint | possible {{RegexChecker}} extension | idea |
> Partially |
> | Untrusted-input tracking | GEP-25 {{@Tainted}} -> regex sink | draft |
> Identifies risky matches |
> | *Runtime guard (this ticket)* | {{RegexGuard}} / {{@SafeRegex}} |
> *implemented* | *Yes -- the actual backstop* |
> {{RegexChecker}} only validates that a literal pattern compiles; it says
> nothing about
> backtracking. As part of this ticket it was extended to recognise the guarded
> forms, so
> the rewrite performed by {{@SafeRegex}} does not blind it: bad regex literals
> are still
> reported, and matcher group-count inference survives the rewrite.
> h2. Ordering with GROOVY-12123
> GROOVY-12123 (constant regex hoisting) and this transform touch the same
> expressions, so
> whichever lands second must be ordering-aware:
> * When {{@SafeRegex}} rewrites {{input ==~ /(a+)+$/}} to
> {{RegexGuard.matchRegex(input, /(a+)+$/, 200)}}, the hoisting pass must
> still recognise
> the pattern -- now the *second* argument, not the first -- as a hoistable
> constant and
> lift it to a {{static final}} field.
> * When hoisting runs first and produces a {{static final Pattern P}},
> {{@SafeRegex}} must
> still recognise a match against {{P}} and wrap the *input* in the deadline
> guard.
> Desired combined result: the constant {{Pattern}} is compiled once into a
> {{static final}}
> field *and* each match wraps the input in the deadline guard.
> h2. Licensing / reuse
> JLine is BSD-3 (ASF category A) and already on the {{groovysh}} classpath
> (jline-builtins/jansi), so {{groovysh}} may use JLine's {{SafeRegex}}
> directly. For *core*
> Groovy the technique is ~20 lines and well-known, so it is reimplemented from
> the
> technique rather than taking a core dependency on JLine. No code was copied.
> h2. References
> * JLine 4.3.1 release notes -- SafeRegex / TimeoutCharSequence:
> https://github.com/jline/jline3/releases/tag/4.3.1
> * CWE-1333: Inefficient Regular Expression Complexity:
> https://cwe.mitre.org/data/definitions/1333.html
> * OWASP -- Regular expression Denial of Service (ReDoS):
> https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
> * Related: GEP-25 (Data-flow Security -- taint tracking); {{RegexChecker}} in
> groovy-typecheckers; {{@TimedInterrupt}} / {{@ThreadInterrupt}} /
> {{@ConditionalInterrupt}} in groovy.transform
> * Relates to: GROOVY-12123 (constant regex hoisting -- compile-once static
> final Pattern fields): https://issues.apache.org/jira/browse/GROOVY-12123
> * Pull request: https://github.com/apache/groovy/pull/2668
--
This message was sent by Atlassian Jira
(v8.20.10#820010)