Skip to content

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.Compile is 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, and errors.Is(err, context.Canceled) is false. If the distinction matters — you are counting rejections, or reporting to a user — check ctx.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:

re, err := regexutil.CompileBoundedTimeout(pattern, 50*time.Millisecond)

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: DefaultCompileTimeout still 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:

  • ErrPatternTooLong costs nothing. The length check happens before any compile work, so an oversize pattern never allocates an automaton.
  • ErrPatternCompileTimeout is 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. ErrPatternInvalid wraps 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.

error parsing regexp: missing closing ]: `[unclosed`: regex pattern is invalid

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:

if pattern == "" {
    return nil, nil // feature disabled — do not fall through to a match-all
}