Skip to content

What regexutil does not do

regexutil bounds one thing: the cost of turning an untrusted pattern string into a compiled regex. Everything below is deliberately outside that scope. Knowing where the boundary sits is the difference between a defence and a false sense of one.

If you are looking for what it does, start with Threat model & the two defences.

It does not bound matching

CompileBounded returns an ordinary *regexp.Regexp. Once you have it, regexutil is out of the picture — it does not wrap the regex, does not intercept MatchString, and applies no deadline to any match you subsequently run.

Go's RE2 engine guarantees matching is linear in the length of the input, which is why this is usually fine. "Linear" is not "free", though: matching a large program against a large input still costs time proportional to both. If you match an untrusted pattern against untrusted input — a whole file, a request body, a log stream — bound that separately, with your own context deadline or a size cap on the input.

It does not bound memory

The two defences are a length cap and a wall-clock timeout. Neither limits how many states the compiled automaton has or how much it allocates. A pattern that compiles inside 100 ms has already been allowed to allocate whatever it needed to get there.

The length cap makes this survivable rather than solved: 1 KiB of pattern can only describe so large a program, and Go's own repeat-count ceiling of 1000 (see Which patterns does Go itself reject?) caps the expansion further. There is no configurable memory bound because Go's regexp package does not expose one.

The bounds are not configurable

MaxPatternLength and DefaultCompileTimeout are const declarations. There is no options struct, no functional option, no environment variable, and no setter. You can read them; you cannot change them.

CompileBoundedTimeout looks like it lets you set the timeout, and it does — downwards only. Your value is layered on top of the package's, so the effective bound is min(yours, 100 ms) and a larger value is silently ignored. This is intentional: a package whose whole purpose is a bound should not offer a way to remove it. If you need a different bound, the honest answer is that this is not the package for you — regexp.Compile under your own context is a few lines.

It does not stop a runaway compile

The timeout unblocks you; it does not stop the work. regexp.Compile is not context-aware, so the compile runs in a goroutine that keeps going after the call has returned your error, until it finishes on its own.

For a stream of distinct pathological patterns, that is a stream of goroutines you cannot cancel — each holding one compile's working set. The reasoning for accepting that, and why the alternative is worse, is in The goroutine-leak tradeoff. The practical consequence: regexutil protects your latency, not your CPU. A determined attacker who can drive many distinct expensive compiles can still cost you cycles.

It is not a rate limiter

Each call is bounded independently. Ten thousand concurrent calls are ten thousand independent 100 ms budgets, not a shared one. If untrusted patterns arrive over the network, put a rate limit or a concurrency bound in front of the compile — regexutil has no notion of how many compiles are in flight.

Compiling the same pattern repeatedly is likewise never deduplicated: there is no cache. A hot path that recompiles a config pattern per request should compile it once at load time and keep the *regexp.Regexp, which is safe for concurrent use.

It does not judge what a pattern means

The bounds are about cost, not about semantics. A pattern that compiles is returned, whatever it matches.

The case that catches people is the empty pattern: "" compiles happily and matches every input, including the empty string. An unset configuration key usually arrives as "", so "the operator left the filter blank" and "the operator asked to match everything" are the same value by the time it reaches CompileBounded. If those mean different things in your tool, decide which before you call. The same applies to .*, to an over-broad character class, or to a pattern that is simply wrong — none of that is a compile-time concern.

It does not keep the pattern out of every error

Rejections for length and for timeout are careful never to echo the pattern, because attacker-chosen text in your logs is its own small denial of service. The invalid-syntax path is different: it wraps Go's parse error, and that message quotes the expression that failed.

That is useful to the operator who mistyped a bracket and hazardous to a log sink fed by strangers. regexutil does not resolve the tension for you — see Does the error contain the pattern? for what to do at the call site.

It does not cover every entry point of regexp

There is one wrapped operation: regexp.Compile. There is no bounded equivalent of regexp.CompilePOSIX, no MustCompile-style panicking variant (a bounded compile can fail on valid input, so panicking would be wrong), and no bounded regexp.Match convenience helper.

Nor does it wrap the pattern in any way: no automatic anchoring, no implicit case-insensitivity, no syntax subsetting. What you pass is what regexp.Compile sees.

It does not tell caller cancellation apart from a slow compile

Both produce ErrPatternCompileTimeout, and neither carries the context's own error, so errors.Is(err, context.Canceled) is false on a call you cancelled yourself. During shutdown, every in-flight compile reports as though its pattern were pathological.

If that distinction matters — say you count timeout rejections as a signal of abuse — check ctx.Err() alongside the returned error rather than trusting the sentinel alone.