Regular Expression Denial of Service (ReDoS) is an algorithmic complexity vulnerability where an innocent-looking regex pattern freezes the CPU when evaluating specific non-matching strings.
Because Node.js, Python, Ruby, and Java use Non-deterministic Finite Automata (NFA) engines for regular expressions, an unoptimized pattern can force the engine to explore millions of permutations via catastrophic backtracking, causing $O(2^n)$ exponential time complexity.
In single-threaded runtimes like Node.js, a single malicious payload can block the event loop entirely, bringing down your entire backend server.
This guide explores how catastrophic backtracking occurs, the 4 classic vulnerable regex patterns, and how to safely refactor them.
1. How Catastrophic Backtracking Happens
Consider the classic vulnerable pattern:
^(a+)+$
When evaluated against matching input like aaaa, the engine resolves quickly.
However, evaluate it against a non-matching input like aaaaaaaaaaaaaaaaaaaaX:
- The inner
a+consumes all 20acharacters. - The outer
+attempts to match, but findsXinstead of$(end of string). - The engine backtracks: it steps back one character, giving 19
as to the first group, and 1ato the second group. - It fails at
Xagain. - It tries every possible mathematical partition of 20 elements across the nested plus operators ($2^{20} = 1,048,576$ step evaluations).
For a string of just 30 characters, the engine requires over 1 billion evaluations, consuming 100% CPU for several minutes.
Input length (n) Steps Evaluated (O(2^n)) Execution Time
----------------------------------------------------------------------
10 chars ~1,024 steps < 0.01 ms
20 chars ~1,048,576 steps ~15 ms
30 chars ~1,073,741,824 steps ~16 seconds
35 chars ~34,359,738,368 steps ~8.5 minutes
2. The 4 Vulnerable Regex Anti-Patterns
1. Nested Quantifiers ((a+)+, ([a-zA-Z0-9]+)*)
- Vulnerable:
^([a-zA-Z0-9_]+)*@example\.com$ - Attack Payload:
aaaaaaaaaaaaaaaaaaaaaaaaaaaa! - Why It Fails: Both quantifiers can match the same characters, creating exponential combinations on failure.
- The Fix: Remove the outer nesting or enforce mutually exclusive character boundaries:
- ✅
^[a-zA-Z0-9_]+@example\.com$
- ✅
2. Overlapping Alternation inside a Loop ((a|a)+, (a|ab)+)
- Vulnerable:
^(https?|http)://.*or(a|b|ab)+$ - Why It Fails: When alternative branches match the same prefix, the engine tries all alternate paths recursively on failure.
- The Fix: Make branches strictly distinct and mutually exclusive:
- ✅
^https?://.*
- ✅
3. Repeated Groups with Overlapping Trailing Matches
- Vulnerable:
^(\d+)+[0-9]$or^(\w+\s*)+$ - Attack Payload:
word word word word word word word...with a trailing invalid character. - The Fix: Ensure clear delimiter separation (e.g.
^\w+(?:\s+\w+)*$).
4. Greedy Quantifiers with Unanchored Wildcards
- Vulnerable:
.*[a-z]+.* - The Fix: Use precise character classes instead of
.*and anchor matches whenever validating input strings.
3. How to Detect and Fix ReDoS in Code
JavaScript / TypeScript Example
// ❌ Vulnerable Email Regex (ReDoS prone on long invalid strings)
const BAD_EMAIL_REGEX = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;
// ✅ Refactored Safe Regex (Linear time O(n))
const SAFE_EMAIL_REGEX = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/;
// Defensive Validation Pattern:
export function validateEmailSafe(input: string): boolean {
// Guard 1: Enforce reasonable maximum string length
if (typeof input !== 'string' || input.length > 254) {
return false;
}
// Guard 2: Run linear-time regex
return SAFE_EMAIL_REGEX.test(input);
}
Setting Regex Timeouts in Runtimes
- Node.js (V8): You can pass
--regexp-timeout=1000to Node.js CLI to abort regex executions exceeding 1000ms. - .NET: Supports native timeouts:
new Regex(pattern, RegexOptions.None, TimeSpan.FromMilliseconds(500)). - Go / Rust: Go's
regexppackage and Rust'sregexcrate use RE2 (guaranteed linear $O(n)$ DFA engines that are immune to catastrophic backtracking).
4. Remediation & Defense Checklist
- Pre-Validate Input Lengths: Never pass unconstrained user input directly into complex regexes. Cap strings at reasonable lengths (e.g. 100–500 characters).
- Eliminate Nested Quantifiers: Flatten expressions like
(A+)+intoA+. - Use Atomic Groups or Possessive Quantifiers: In engines supporting them (PCRE, Java, PHP), use possessive quantifiers (
++) to prevent the engine from saving backtracking states. - Prefer String Methods When Applicable: For simple prefix/suffix checks, use
str.startsWith(),str.endsWith(), orstr.includes()rather than spinning up regular expressions.
Frequently Asked Questions
Why doesn't Google's RE2 engine suffer from ReDoS?
RE2 and Go's regex engines compile patterns into Deterministic Finite Automata (DFA) instead of backtracking NFAs. DFAs process input characters strictly in linear time ($O(n)$) without ever backtracking, completely eliminating ReDoS attacks (at the trade-off of disallowing backreferences).
How can I inspect and test regular expressions for backtracking?
Test your patterns with sample inputs and breakdown group mechanics using the Regex Tester Tool and Regex Explainer Tool.