Repository navigation
Regexp.timeout= is stored but never applied, and a fired limit returns nil instead of raising Regexp::TimeoutError (fail-open for validators) #776
Description
Summary
Two separate defects in the same defence, both of which make a regexp used as a validator fail open:
Regexp.timeout=is stored, echoed back, and never applied. The class-level setter is Ruby 3.2's documented ReDoS defence. Setting it changes nothing, andRegexp.timeoutfaithfully reports the value you set — so a host that configured it has no way to notice it is inert.- When a limit does fire, the match returns
nilinstead of raising.Regexp::TimeoutErrorcan never be rescued. For a regexp used to validate or to deny, "timed out" is therefore indistinguishable from "does not match", so a crafted subject passes the check.
Found during the security audit tracked in #768.
Measurement
p1 = Regexp.new("(a+)+\\1b"); s = "a"*26
puts "Regexp.timeout initially: #{Regexp.timeout.inspect}"
# A: no timeout at all
Regexp.timeout = 0.05
puts "reads back: #{Regexp.timeout.inspect}"
# B: class-level
p2 = Regexp.new("(a+)+\\1b", timeout: 0.05)
# C: per-Regexp| rbgo | MRI 4.0.5 | |
|---|---|---|
Regexp.timeout initially |
nil |
nil |
| A no timeout | nil after 0.994 s, no exception |
nil after 1.775 s, no exception |
reads back after = 0.05 |
0.05 |
0.05 |
B class-level Regexp.timeout = 0.05 |
nil after 0.843 s, no exception |
Regexp::TimeoutError after 0.050 s |
C per-Regexp timeout: 0.05 |
nil after 0.050 s, no exception |
Regexp::TimeoutError after 0.050 s |
Row C is what makes this readable rather than a guess about an absence: the per-regexp option is demonstrably enforced — 0.050 s, to the millisecond, against 0.843 s unlimited — which proves the probe shape works and the plumbing exists. So row B's 0.843 s is a measurement of the class-level setter being ignored, 17× over its own limit, and not a probe that failed to arrive.
Row C also shows the second defect on its own: the limit fired, and the answer was nil.
Where, as far as I have read
- Only the per-
Regexptimeout:option is threaded through to the engine'sWithTimeoutininternal/vm/regexp.go. The class-level setter has a reader and a writer and no consumer. go-regexp/engine/regexp.gocollapses the two outcomes: the match path isif err != nil || !matched {— soErrTimeoutandErrBudgetboth become a clean non-match rather than an error the VM could turn intoRegexp::TimeoutError.
Worth saying, because it changes what needs fixing: the engine is not defenceless. It is a hand-written backtracking VM (go-regexp/engine, not RE2), but with a step budget, a wall-clock deadline, a \g<> call-depth cap and (pc,sp) memoisation that makes the RE2-compatible subset linear. The classic one-line ReDoS does not reproduce: /(a+)+b/ =~ "a"*40 is 0.001 s. Measured against MRI on the backreference form, which memoisation correctly cannot help, rbgo is better: at "a"*26 rbgo takes 0.860 s where MRI takes 5.032 s, and at "a"*30 rbgo finishes in 2.257 s where MRI is OOM-killed at 2 GB.
So this is not "rbgo is open to ReDoS". It is that the limits which exist are not reportable, and the one knob a host is told to use is not wired up.
Suggested shape
- Thread the class-level
Regexp.timeoutto the engine, or make the setter raiseNotImplementedErrorso a host finds out at configuration time instead of never. Silently accepting a security setting is the worse of the two. - Separate
ErrTimeout/ErrBudgetfrom "no match" at theif err != nil || !matchedsite and raiseRegexp::TimeoutError. Even if the class-level setter stays unimplemented, this alone turns a fail-open into a fail-closed. - A test asserting that a timed-out match raises rather than returning
nil— the current behaviour is exactly the one a validator cannot distinguish from success.
🤖 Generated with Claude Code
Activity
test: cover raiseMatchLimit's defensive arm and matcher's vm == nil case
tannevaled commentedon Oct 5, 2026
Correcting two things in the issue body — both mine, and the second changes the severity
1. "rbgo beats MRI" was not a speed result
The body says, as a mitigating note:
rbgo did not finish. It reached the step budget and reported
nil. I measured wall-clock time on a call whose failure was being swallowed, and read the silence as a win — the engine's hardening is real, but on that input it bought a silent refusal, not a faster answer. MRI's 1.7–5 s on the same subject is an exhaustive search; MRI has no step budget.This is the proxy-measure trap in its plainest form: the quantity I timed was not the quantity I was claiming.
2. The fail-open did not need
Regexp.timeoutto be configured — it was the defaultRow A of the table says "no timeout:
nilafter 0.994 s", which I presented as the unlimited baseline against which rows B and C were judged. Measured at the engine boundary, that row was already the budget firing:(a+)+\1b"a"*20"a"*22"a"*24"a"*26So the issue understated itself. I framed this as "the limits that exist are not reportable", with the implication that a host had to configure something before the fail-open was reachable. It was reachable at default settings, with no timeout set at all — which makes it a plain fail-open in the default configuration rather than a misconfiguration hazard.
Both corrections come from #790's own measurements, which I verified before accepting: on
mainthe no-timeout case answersnilafter 0.834 s, and a matching subject still matches in 0.000 s on both binaries, so the change only affects subjects that exhaust the budget.On the class, since I nearly objected to it
I was going to argue that budget exhaustion should raise
Regexp::TimeoutError, because that is the class a defensive program rescues. Reading MRI first settled it the other way:Regexp::TimeoutErroris defined underRegexpError(re.c:4862), and an onig resource error is a plainRegexpError(re.c:1724-1728). So MRI's own taxonomy separates "ran out of time" from "ran out of resource", andRegexpErrorfor the budget is the conformant choice. Noted here because the reasoning is worth more than the conclusion.🤖 Generated with Claude Code
fix: report a timeout or budget limit instead of folding it into "no …
fix: report a timeout or budget limit instead of folding it into "no …
fix(regexp): raise on a reached match limit, and wire up Regexp.timeo…