Compile an untrusted pattern¶
This guide covers the practical choices you make when compiling a
user- or config-supplied regex with regexutil: which of the two entry points
to call, how to think about the timeout, and how to branch on the failure modes.
If you are new to the package, work through Getting started first; this page assumes you know what the sentinels mean and focuses on the decisions.
Choose an entry point¶
regexutil exposes exactly two functions. Pick by whether your call site
already carries a context.Context.
CompileBounded — you have a context¶
Use this from anywhere that already threads a context.Context (a request
handler, a command's RunE, any function whose caller passes one down). It
applies the MaxPatternLength cap and a DefaultCompileTimeout wall-clock
bound, while still honouring any earlier deadline or cancellation on the context
you pass.
func compileFilter(ctx context.Context, pattern string) (*regexp.Regexp, error) {
return regexutil.CompileBounded(ctx, pattern)
}
If the context you pass is cancelled — or its own deadline fires — before the
compile finishes, the call returns on the ErrPatternCompileTimeout path just
as it would on the package's internal timeout, so a shutdown or a per-request
deadline releases your goroutine promptly.
Two consequences to keep in mind:
- The compile itself is not aborted.
regexp.Compileis not context-aware, so cancelling frees you, while the compile goroutine runs to completion in the background. See the goroutine-leak tradeoff. - You cannot tell cancellation from a slow pattern by the error. Both
produce
ErrPatternCompileTimeout, anderrors.Is(err, context.Canceled)is false. If the distinction matters — you are counting rejections, or reporting to a user — checkctx.Err()alongside the returned error.
Passing a context that is already cancelled or expired fails the same way, whatever the pattern.
CompileBoundedTimeout — you have no context¶
Use this from a call site that does not naturally carry a context — a Bubble Tea
TUI update loop, a small helper, an init-style path. It builds a context from
context.Background() with the timeout you supply and delegates to
CompileBounded:
Choosing a timeout¶
You cannot widen the package's wall-clock bound. CompileBoundedTimeout
applies the caller's timeout on top of CompileBounded, which itself layers
DefaultCompileTimeout (100 ms) onto the context — so the effective bound is the
minimum of your value and 100 ms.
- Passing a value larger than 100 ms has no effect:
DefaultCompileTimeoutstill wins. You cannot accidentally give a pathological pattern more room. - Passing a value smaller than 100 ms tightens the bound — useful for a latency-sensitive interactive loop where even 100 ms of freeze is too much.
- Passing zero or a negative value fails every call, including on a trivially valid pattern: the context is already expired before the compile starts. If the timeout comes from configuration, clamp it to a positive value first.
// Tighten to 25 ms for a keystroke-driven TUI filter; the effective bound is
// min(25ms, 100ms) = 25ms.
re, err := regexutil.CompileBoundedTimeout(pattern, 25*time.Millisecond)
For most non-interactive call sites, prefer CompileBounded and let the 100 ms
default stand — it is three orders of magnitude above a realistic compile. The
full table of effective bounds, including the edge cases, is in Limits and
timeouts.
Handle the failure modes¶
Both functions return an error that wraps one of three sentinels. Branch with
errors.Is — never ==, because the returned value is a wrapped error carrying
a user-facing hint, not the bare sentinel.
import "github.com/cockroachdb/errors"
re, err := regexutil.CompileBounded(ctx, pattern)
switch {
case errors.Is(err, regexutil.ErrPatternTooLong):
// Rejected before compiling: pattern exceeded MaxPatternLength (1024 bytes).
return fmt.Errorf("pattern too long (max %d bytes)", regexutil.MaxPatternLength)
case errors.Is(err, regexutil.ErrPatternCompileTimeout):
// Compilation exceeded the wall-clock bound — treat as pathological.
return errors.New("pattern too complex to compile safely; simplify it")
case errors.Is(err, regexutil.ErrPatternInvalid):
// regexp.Compile rejected the syntax.
return errors.Wrap(err, "invalid regex")
case err != nil:
// No other kind is expected, but do not swallow surprises.
return err
}
// err == nil: re is safe to use.
use(re)
A few points worth internalising:
ErrPatternTooLongcosts nothing. The length check happens before any compile work, so an oversize pattern never allocates an automaton.ErrPatternCompileTimeoutis the expense signal, not proof of malice. A short pattern that times out is exactly the case RE2's linear matching does not protect you from — see the threat model. But a valid, repetition-heavy pattern at the 1 KiB cap can also exceed 100 ms on a busy machine, so word the message you show the operator as "too expensive to compile", not "rejected as an attack".- Two of the three errors never echo your pattern; one does. The too-long
and timeout errors carry the length and a remediation hint only.
ErrPatternInvalidwraps Go's parse error, which quotes the offending expression — see Log a rejection without logging the pattern.
Read the length out of a rejection¶
The pattern's length is attached as a cockroachdb/errors hint, which is
not part of err.Error(). Ask for it explicitly, or your user never sees the
one number that tells them how much to cut:
if errors.Is(err, regexutil.ErrPatternTooLong) {
// "pattern has 1793 bytes; max is 1024"
return fmt.Errorf("pattern rejected: %s", errors.FlattenHints(err))
}
ErrPatternCompileTimeout carries a fixed remediation hint in the same way;
ErrPatternInvalid carries none, because its message already says what is
wrong.
Log a rejection without logging the pattern¶
Attacker-chosen text in your logs is a denial of service of its own. The length
and timeout errors are built not to contain any, so they are safe to log whole.
ErrPatternInvalid is not: it wraps Go's syntax error, and that message quotes
the expression that failed to parse — usually the entire pattern, up to the
full 1024 bytes the cap allows.
Return that message to whoever wrote the pattern — it is how they find the missing bracket — but keep it out of a log sink when the source is untrusted:
switch {
case errors.Is(err, regexutil.ErrPatternInvalid):
// Safe to return to the operator; not safe to log verbatim.
log.Warn("rejected pattern: invalid syntax", "source", cfgKey)
return userFacing(err)
default:
// Length and timeout rejections contain no attacker-chosen text.
log.Warn("rejected pattern", "err", err, "hint", errors.FlattenHints(err))
return err
}
Decide what an empty pattern means before you call¶
"" is a valid pattern. It compiles, and the regex it produces matches every
input. An unset config key usually arrives as "", so "the operator left this
blank" and "the operator asked to match everything" reach CompileBounded as
the same value — and it will return a working regex for both.
If those mean different things in your tool, branch before compiling:
Related¶
- Getting started — the guided first-use walkthrough.
- Threat model & the two defences — why both a length cap and a timeout, and which call sites actually need them.
- What regexutil does not do — matching, memory, concurrency and the other things these bounds do not cover.
- Errors — exact messages, hints, and what
errors.Ismatches. - Limits and timeouts — the two bounds and every edge case around them.