Dev.to Security 🔐 Cybersecurity 👁 0 📖 2 min read

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

  1. A length limit is not enough. 28 characters is short. Plenty of real input is that long.
  2. 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.
  3. Possessive quantifiers don't exist in JavaScript. new RegExp('^a++$') throws Invalid regular expression: /^a++$/: Nothing to repeat. Perl and PCRE have a++; .NET has Regex.MatchTimeout; PHP has pcre.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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.