
Preventing ReDoS in Rust: Writing Regular Expressions That Stay Fast Under Attack
What ReDoS looks like in practice
ReDoS happens when an attacker supplies input that causes a regex engine to do disproportionate work. In backtracking engines, patterns with nested repetition or ambiguous alternation can explode in runtime. Even in Rust, where the default regex crate uses a linear-time engine, you can still create expensive behavior through repeated scans, large haystacks, or by relying on features that force slower matching strategies.
A typical example is user-facing validation:
- checking usernames, emails, or identifiers
- extracting fields from logs or HTTP headers
- filtering search queries or route parameters
If these checks run on every request, a single pathological input can tie up CPU and reduce service capacity.
Why Rust helps, and where the risk remains
The standard regex crate avoids catastrophic backtracking by design. It compiles patterns into automata that guarantee linear-time matching with respect to the input size. That is a major security advantage.
However, there are still practical risks:
- Unbounded input size: even linear-time matching can be expensive on very large strings.
- Repeated matching: calling the same regex many times in a loop can amplify cost.
- Fallback features: some advanced constructs are unsupported by the default engine and may push you toward slower alternatives.
- Poor pattern design: while not catastrophic in the classic sense, some patterns still cause unnecessary work or surprising behavior.
The goal is not just “avoid catastrophic backtracking,” but “make regex use predictable and bounded.”
Prefer the default regex crate
For most application code, use the regex crate from crates.io and avoid engines that support backreferences or look-around unless you have a strong reason. Those features are often what make regexes expressive, but they also make matching harder to reason about.
Safe default behavior
use regex::Regex;
fn is_valid_username(input: &str) -> bool {
// Anchored, simple, and linear-time under the default engine.
let re = Regex::new(r"^[a-zA-Z0-9_]{3,32}$").unwrap();
re.is_match(input)
}
fn main() {
println!("{}", is_valid_username("alice_42"));
}This pattern is easy to audit:
^and$anchor the full string- the character class is explicit
- the quantifier has a clear upper bound
That combination is ideal for security-sensitive validation.
Avoid ambiguous patterns
The most important habit is to keep patterns unambiguous. Ambiguity often appears when multiple alternatives can match the same prefix, or when nested repetition can match the same text in many ways.
Risky pattern shapes
| Pattern shape | Why it is risky | Safer alternative | |
|---|---|---|---|
(a+)+ | Nested repetition can be hard to reason about | a+ with a length limit | |
| `(foo | fo)+` | Overlapping alternatives create ambiguity | Reorder or simplify the tokenization |
.* in unanchored searches | Can scan more than intended | Anchor the pattern or use explicit classes | |
(.+)? | Optional greedy groups can be unclear | Use a precise character class and bounds |
In Rust’s default engine, these patterns do not usually produce exponential blowups, but they still make code harder to maintain and can increase work on large inputs. Security-oriented regexes should be boring.
Bound the input before matching
Even a linear-time regex can become a problem if you feed it arbitrarily large data. Always consider a maximum input size before matching.
use regex::Regex;
const MAX_HEADER_LEN: usize = 256;
fn parse_token_header(header: &str) -> Option<&str> {
if header.len() > MAX_HEADER_LEN {
return None;
}
let re = Regex::new(r"^Bearer ([A-Za-z0-9._~-]+)$").unwrap();
re.captures(header)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
}This approach protects you in two ways:
- it rejects oversized input early
- it limits the amount of work the regex engine must do
For network-facing services, size limits should be enforced as close to the boundary as possible, ideally before parsing or allocation.
Compile regexes once
Creating a regex repeatedly is wasteful. Compilation can be much more expensive than matching, and doing it per request can become a self-inflicted performance issue.
Use once_cell or lazy_static to compile once and reuse the pattern.
use once_cell::sync::Lazy;
use regex::Regex;
static EMAIL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
});
fn is_email(input: &str) -> bool {
EMAIL_RE.is_match(input)
}This is not only faster, but also easier to test and audit. A single shared compiled regex reduces the chance of accidental pattern drift across the codebase.
Prefer exact matching over extraction when possible
If you only need to validate whether a string fits a format, use is_match with anchors rather than extracting captures you do not need. Captures add complexity and can obscure the intent of the check.
Example: validating an API key format
use once_cell::sync::Lazy;
use regex::Regex;
static API_KEY_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^sk_[A-Za-z0-9]{32,64}$").unwrap()
});
fn is_valid_api_key(candidate: &str) -> bool {
candidate.len() <= 70 && API_KEY_RE.is_match(candidate)
}This pattern is explicit about:
- prefix
- allowed characters
- length range
- maximum input size
That combination is much safer than a loose “anything goes” regex followed by ad hoc checks.
Be careful with repeated searches in loops
A common performance bug is scanning the same text multiple times with different regexes. If the input is attacker-controlled, that can multiply the cost of a request.
For example, do not write code like this unless the input is small and trusted:
for re in ®exes {
if re.is_match(large_text) {
// ...
}
}If you need to classify text, consider:
- combining patterns where practical
- parsing once and reusing the result
- using a tokenizer or state machine instead of many regexes
Regex is a good tool for matching, but not always the best tool for full-text classification pipelines.
Use regex only for the part it is good at
Security bugs often appear when developers ask regex to do too much. A regex can validate structure, but it should not replace proper parsing for nested or context-sensitive formats.
Good uses
- checking a short identifier format
- extracting a version string
- validating a simple token prefix
- splitting a line into fixed fields
Poor uses
- parsing nested parentheses
- validating complex programming languages
- handling recursive grammars
- interpreting structured documents with many edge cases
If your pattern starts to look like a mini parser, switch to a parser. That is usually safer and easier to maintain.
Test against adversarial input
Security-oriented regexes should be tested with both valid and invalid examples. Include inputs that are long, repetitive, and near-miss cases that almost match.
A useful test strategy is to create a corpus of:
- valid short inputs
- invalid short inputs
- very long inputs
- repeated prefixes
- strings that differ only at the last character
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_oversized_input() {
let input = "a".repeat(10_000);
assert!(!is_valid_username(&input));
}
#[test]
fn accepts_normal_username() {
assert!(is_valid_username("dev_user_1"));
}
}You should also benchmark critical regexes under realistic load. A pattern that is fine in unit tests may still be too costly when applied to large logs or request bodies.
Know when to use a different crate or approach
Not every regex use case fits the same engine. The table below summarizes common choices.
| Approach | Best for | Security note |
|---|---|---|
regex crate | General validation and extraction | Linear-time matching, good default |
| Manual parsing | Fixed formats and strict protocols | Often more predictable than regex |
| Tokenization + state machine | Structured text with multiple rules | Easier to bound and audit |
| Advanced regex engines | Specialized features like backreferences | Can reintroduce performance risk |
If you need features not supported by Rust’s default regex engine, evaluate whether the feature is truly necessary. Often the safer design is to simplify the format instead of expanding the matcher.
Practical checklist for secure regex use
Before shipping a regex into production, review it against this checklist:
- Is the input size bounded before matching?
- Is the pattern anchored when validating a full string?
- Are the character classes and quantifiers explicit?
- Are repeated matches avoided in hot paths?
- Is the regex compiled once and reused?
- Are tests included for long and near-miss inputs?
- Could a parser or manual check be simpler?
If you can answer “yes” to the first six and “no” to the last one, you are probably in good shape.
A secure validation pattern in context
Here is a realistic example for a web service that accepts a short slug in a URL path segment.
use once_cell::sync::Lazy;
use regex::Regex;
static SLUG_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").unwrap()
});
pub fn validate_slug(slug: &str) -> bool {
if slug.len() > 64 {
return false;
}
SLUG_RE.is_match(slug)
}Why this is a good security pattern:
- the length is capped
- the regex is anchored
- the allowed alphabet is narrow
- the hyphen rule is explicit
- the regex is reused rather than recompiled
This is the kind of code that remains understandable months later, which is a security feature in itself.
Conclusion
ReDoS prevention in Rust is less about escaping catastrophic backtracking and more about writing regexes that are simple, bounded, and easy to reason about. The default regex crate gives you a strong foundation, but you still need to control input size, avoid ambiguous patterns, and keep regex usage focused on the right problem.
When in doubt, choose the simplest pattern that expresses the rule, compile it once, and test it with adversarial inputs. Predictability is the real defense.
