[ 
https://issues.apache.org/jira/browse/GROOVY-12122?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King updated GROOVY-12122:
-------------------------------
    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 (see _Possible future work_ below)
* 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. Interaction with GROOVY-12123 (Groovy 7)

This ticket targets Groovy 6. GROOVY-12123 (constant regex hoisting) targets 
Groovy 7, so
it lands second and owns the interaction: by then the guarded forms above are 
already
shipped and in use. Within a single compilation the two still touch the same 
expressions,
so the hoisting pass must be aware of the compile-phase ordering:

* If hoisting runs after {{@SafeRegex}} (which rewrites at 
{{SEMANTIC_ANALYSIS}}), it sees
  {{RegexGuard.matchRegex(input, /(a+)+$/, 200)}} instead of a bare operator. 
The pattern
  is the *second* argument of {{matchRegex}} / {{findRegex}} -- and the *first* 
of a
  hand-written {{matches}} / {{matcher}} -- and must still be recognised as a 
hoistable
  constant and lifted to a {{static final}} field.
* If hoisting runs first and produces a {{static final Pattern P}}, nothing 
further is
  needed: {{@SafeRegex}} rewrites a {{==~}} / {{=~}} expression whatever its 
right operand
  is, and {{RegexGuard}} accepts a {{Pattern}} directly, so {{input ==~ P}} 
already guards
  correctly today.

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. Possible future work

*Guarding regex method calls within a {{@SafeRegex}} scope.* The transform 
rewrites only
the {{==~}} and {{=~}} operators, so a call such as 
{{input.replaceAll(/(a+)+$/, 'x')}}
inside an annotated scope stays unguarded. This deserves its own ticket rather 
than a
documentation footnote, because for the most common shape -- a {{String}} 
receiver with
{{String}} arguments -- a compile-time rewrite is the *only* mechanism that can 
work:
{{matches(String)}}, {{replaceAll(String, String)}}, {{replaceFirst(String, 
String)}} and
{{split(String)}} all dispatch to {{java.lang.String}} and never enter Groovy's 
runtime, so
no runtime hook, the ambient guard included, can ever observe them. Their
{{Pattern}}-argument variants are extension methods and are already covered.

Such a rewrite would have to be receiver-aware, since these names exist on 
unrelated types
({{List#replaceAll(UnaryOperator)}}, Groovy's own 
{{Collection#split(Closure)}}) and the
receiver type is generally unknown at {{SEMANTIC_ANALYSIS}}. Two workable 
routes:

* redirect to a runtime-dispatching helper that guards a {{CharSequence}} 
receiver and
  otherwise forwards the call unchanged, preserving dynamic semantics; or
* restrict the rewrite to statically-typed code, where the receiver type is 
known.

*Whole-body guarding as an additional {{@SafeRegex}} strategy.* Instead of 
rewriting
individual expressions, the transform could wrap an annotated body in the 
ambient guard
(form (c) above). That would cover everything reachable dynamically on the 
calling thread
-- the operators, the {{CharSequence}} extension methods, and regex in *called* 
methods,
which a lexical rewrite can never reach.

The two strategies are complementary rather than one dominating the other, so 
this would
be an opt-in mode alongside the current rewrite, not a replacement:

* Dynamic scope covers called code; lexical scope does not.
* Lexical scope covers a closure defined in the annotated scope but invoked 
later, or on
  another thread, because the rewrite is baked into the closure's body. Dynamic 
scope
  would not, since the binding unwinds when the annotated method returns.
* Dynamic scope costs a scoped binding per invocation, and what is guarded then 
depends on
  the call graph rather than on what is visible in the source, which is harder 
to audit.
* Neither closes the {{java.lang.String}} dispatch gap described above.

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

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 hardening of 
Groovy's
own internal regex call sites.

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.

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 can 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 a
{{RegexTimeoutException}} once exceeded; because the regex engine calls 
{{charAt()}}
continuously while backtracking, a runaway match is interrupted with no extra 
thread and
no {{Thread.interrupt}} plumbing.

Three deliverables, from lowest-level to most ergonomic:

* *(a) Internal hardening* -- wrap regex matches against user-supplied input in 
Groovy's
  own runtime and tools (templating, {{groovysh}} completion, builders), 
exactly as JLine
  did across its modules. Invisible; no public API change.

* *(b) Runtime helper ({{RegexGuard}})* -- an explicit guarded-match API for 
user code and
  for anything the annotation cannot reach:

{code:groovy}
RegexGuard.matches(pattern, input, Duration.ofMillis(200))   // -> boolean, 
throws on timeout
RegexGuard.matcher(pattern, input, Duration.ofMillis(200))   // -> Matcher over 
a guarded input
{code}

* *(c) Scoped annotation ({{@SafeRegex}})* -- at method / class / package 
level; an AST
  transform rewrites the regex match operations lexically within scope to the 
{{RegexGuard}}
  path, 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.matches(~/(a+)+$/, 
input, 200ms)
    }
}
{code}

The guard is on *matching*, not on {{~/.../}} compilation, so the transform 
rewrites the
match operations ({{=~}}, {{==~}}, {{String#matches}}, 
{{replaceAll}}/{{replaceFirst}},
{{split}}, {{Matcher}} operations), not {{Pattern.compile}}.

h3. Naming

|| Concern || Name || Notes ||
| Scoped annotation | {{@SafeRegex}} | in {{groovy.transform}}, alongside 
{{@TimedInterrupt}} et al. |
| Runtime helper | {{RegexGuard}} | distinct from the annotation to avoid a 
clash |
| Timeout exception | {{RegexTimeoutException}} | unchecked; lets callers 
distinguish a ReDoS abort from a normal non-match |

h2. Scope

*In scope*
* Runtime deadline guard ({{TimeoutCharSequence}}-style), thread-free
* Internal hardening of Groovy's own user-input regex sites
* {{RegexGuard}} helper and {{@SafeRegex}} scoped annotation for user code
* {{RegexTimeoutException}} type

*Out of scope / deferred*
* Making the guard the default for {{=~}} / {{==~}} (behaviour change + 
overhead)
* Static ReDoS pattern detection -- a possible future {{RegexChecker}} 
extension, not this ticket
* Rewriting the regex engine or adopting a non-backtracking (RE2-style) engine

h2. Limitations

* {{@SafeRegex}} rewrites only *lexically-visible* regex ops -- scoped 
hardening, not
  whole-program. A regex executed inside a called library method is not 
rewritten; use
  {{RegexGuard}} explicitly there.
* The deadline fires only while the engine is calling {{charAt}} (the 
backtracking phase) --
  true of catastrophic cases but not a hard guarantee for every pathological 
state.
* Each {{charAt}} pays a small deadline check ({{System.nanoTime}}), so the 
guard is opt-in /
  internal-only, never a blanket default.
* Reduces hang risk; it is not a correctness or a data-at-rest control.

h2. Relationship to existing 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}} | *missing* | 
*Yes -- the actual backstop* |

{{RegexChecker}} only validates that a literal pattern compiles; it says 
nothing about
backtracking. This ticket fills the runtime layer none of the others cover. It 
also
composes with GEP-25: a regex applied to a {{@Tainted}} string is a ReDoS sink, 
and a
{{RegexGuard}}-guarded (or {{@SafeRegex}}-scoped) match is its runtime 
sanitizer.

Here's the section with `GROOVY-12123` slotted in — ready to paste into 
GROOVY-12122.

h2. Relates to / composes with GROOVY-12123 (constant regex hoisting)

GROOVY-12123 hoists effectively-constant regexes to synthetic {{static final 
Pattern}}
fields so they compile once. The two tickets touch the same regex AST surface 
and sit on
opposite ends of the same operation -- that ticket optimizes *Pattern 
construction*, this
one guards *matching* -- so they compose, but the transforms must be 
ordering-aware:

* When {{@SafeRegex}} rewrites {{input ==~ /(a+)+$/}} to
  {{RegexGuard.matches(~/(a+)+$/, input, ...)}}, the hoisting pass must still 
recognize the
  {{~/(a+)+$/}} argument 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 recognize 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. Whoever 
implements the second
of the two should account for the first.

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 reimplement the idea 
rather than take
a core dependency on JLine. Do not copy code; implement from the technique.

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



> Provide a Regex timeout facility
> --------------------------------
>
>                 Key: GROOVY-12122
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12122
>             Project: Groovy
>          Issue Type: Improvement
>          Components: regex
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>              Labels: GEP
>
> 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 (see _Possible future work_ below)
> * 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. Interaction with GROOVY-12123 (Groovy 7)
> This ticket targets Groovy 6. GROOVY-12123 (constant regex hoisting) targets 
> Groovy 7, so
> it lands second and owns the interaction: by then the guarded forms above are 
> already
> shipped and in use. Within a single compilation the two still touch the same 
> expressions,
> so the hoisting pass must be aware of the compile-phase ordering:
> * If hoisting runs after {{@SafeRegex}} (which rewrites at 
> {{SEMANTIC_ANALYSIS}}), it sees
>   {{RegexGuard.matchRegex(input, /(a+)+$/, 200)}} instead of a bare operator. 
> The pattern
>   is the *second* argument of {{matchRegex}} / {{findRegex}} -- and the 
> *first* of a
>   hand-written {{matches}} / {{matcher}} -- and must still be recognised as a 
> hoistable
>   constant and lifted to a {{static final}} field.
> * If hoisting runs first and produces a {{static final Pattern P}}, nothing 
> further is
>   needed: {{@SafeRegex}} rewrites a {{==~}} / {{=~}} expression whatever its 
> right operand
>   is, and {{RegexGuard}} accepts a {{Pattern}} directly, so {{input ==~ P}} 
> already guards
>   correctly today.
> 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. Possible future work
> *Guarding regex method calls within a {{@SafeRegex}} scope.* The transform 
> rewrites only
> the {{==~}} and {{=~}} operators, so a call such as 
> {{input.replaceAll(/(a+)+$/, 'x')}}
> inside an annotated scope stays unguarded. This deserves its own ticket 
> rather than a
> documentation footnote, because for the most common shape -- a {{String}} 
> receiver with
> {{String}} arguments -- a compile-time rewrite is the *only* mechanism that 
> can work:
> {{matches(String)}}, {{replaceAll(String, String)}}, {{replaceFirst(String, 
> String)}} and
> {{split(String)}} all dispatch to {{java.lang.String}} and never enter 
> Groovy's runtime, so
> no runtime hook, the ambient guard included, can ever observe them. Their
> {{Pattern}}-argument variants are extension methods and are already covered.
> Such a rewrite would have to be receiver-aware, since these names exist on 
> unrelated types
> ({{List#replaceAll(UnaryOperator)}}, Groovy's own 
> {{Collection#split(Closure)}}) and the
> receiver type is generally unknown at {{SEMANTIC_ANALYSIS}}. Two workable 
> routes:
> * redirect to a runtime-dispatching helper that guards a {{CharSequence}} 
> receiver and
>   otherwise forwards the call unchanged, preserving dynamic semantics; or
> * restrict the rewrite to statically-typed code, where the receiver type is 
> known.
> *Whole-body guarding as an additional {{@SafeRegex}} strategy.* Instead of 
> rewriting
> individual expressions, the transform could wrap an annotated body in the 
> ambient guard
> (form (c) above). That would cover everything reachable dynamically on the 
> calling thread
> -- the operators, the {{CharSequence}} extension methods, and regex in 
> *called* methods,
> which a lexical rewrite can never reach.
> The two strategies are complementary rather than one dominating the other, so 
> this would
> be an opt-in mode alongside the current rewrite, not a replacement:
> * Dynamic scope covers called code; lexical scope does not.
> * Lexical scope covers a closure defined in the annotated scope but invoked 
> later, or on
>   another thread, because the rewrite is baked into the closure's body. 
> Dynamic scope
>   would not, since the binding unwinds when the annotated method returns.
> * Dynamic scope costs a scoped binding per invocation, and what is guarded 
> then depends on
>   the call graph rather than on what is visible in the source, which is 
> harder to audit.
> * Neither closes the {{java.lang.String}} dispatch gap described above.
> 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)

Reply via email to