A 28-Character String Freezes Your Regex for 3.6 Seconds
Your test suite passes, staging passes, and then one user submits a 28-character string and a Node process pins a core for 3.6 seconds. I measured this today on Node 22 (V8) with /^(a+)+$/ against an input of n letters
Your test suite passes, staging passes, and then one user submits a 28-character string and a Node process pins a core for 3.6 seconds.
I measured this today on Node 22 (V8) with /^(a+)+$/ against an input of n letters a followed by a single X that makes the match fail:
| input | result | time |
|---|---|---|
| 20 chars | no match | 8.6 ms |
| 22 chars | no match | 34.2 ms |
| 24 chars | no match | 136.6 ms |
| 26 chars | no match | 553.7 ms |
| 27 chars | no match | 1,103.8 ms |
| 28 chars | no match | 3,586.4 ms |
Every extra character doubles the work. Twelve more characters is 2^12 = 4,096x, so the same 40-character input would take roughly four hours.
The same pattern against 'a'.repeat(5000) with no trailing X returns a match in 0 ms. That is the whole trap: catastrophic backtracking only fires when the match fails. Happy-path tests never touch it. A malformed query param, a truncated field, or a name with one unexpected character does.
The cause is ambiguity inside a nested quantifier. (a+)+ lets the engine split the same run of as in every possible way, roughly 2^(n-1) partitions for n characters, and "no match" means it has to try all of them before giving up.
This is not a contrived pattern. A very ordinary email validator — ^([a-zA-Z0-9])(([-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}([a-z]{2,3})$ — measured 0.4 ms at 16 characters, 12.5 ms at 24, and 200.7 ms at 28 once I appended !. Same shape, same failure mode.
Three fixes that don't work
- A length limit is not enough. 28 characters is short. Plenty of real input is that long.
-
An upper bound is not a fix.
^(a+){1,64}$measured 4,414 ms at 26 characters, against 553.7 ms for the unbounded version. The 64-way outer loop multiplies the number of splits instead of capping the depth. -
Possessive quantifiers don't exist in JavaScript.
new RegExp('^a++$')throwsInvalid regular expression: /^a++$/: Nothing to repeat. Perl and PCRE havea++; .NET hasRegex.MatchTimeout; PHP haspcre.backtrack_limit(1,000,000 by default). V8 has neither a limit nor a timeout — it just blocks the event loop.
What actually works
Rewrite the repetition so there is only one way to match. ^a+$ rejected the failure case on 1,000,000 characters in 3.2 ms — three orders of magnitude faster on input 35,000 times longer.
If you cannot rewrite it, emulate an atomic group with a lookahead and a backreference: /^(?=(a+))\1$/ ran in 0 ms on the exact 27-character input that took 1,103.8 ms, and still returns the right answer for "aaa" (true), "a" (true), "" (false) and "aab" (false).
Finally, bound the blast radius: cap input length, run untrusted patterns inside a worker with a timeout, or use RE2, which is a linear-time engine by construction.
When I sanity-check a pattern I keep this regex tester open and test the failing path on purpose — a long valid prefix plus one invalid trailing character. If the tab hangs, so will your server.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.