Here's a regex. It validates that a string is a list of comma-separated words. Twelve characters. Take a second and decide whether you'd approve it in a PR.
const RE = /^(\w+,?)+$/;
I'd have approved it. It reads correctly, it does what it says, and it passes every test you'd naturally write:
RE.test("alpha") // true, instant
RE.test("alpha,beta,gamma") // true, instant
RE.test("alpha,,beta") // true, instant
RE.test("") // false, instant
Now hand it a string that is 30 characters long and doesn't match:
RE.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")
On my machine that call doesn't return. Not "returns slowly" — the event loop is gone. Node is pinned at 100% of one core doing regex backtracking, and because JavaScript's regex engine has no timeout and no interrupt, nothing you can do from JS will stop it. No AbortController, no Promise.race, no setTimeout. The only exit is killing the process.
If that regex is validating a request body, you have just shipped a single-request denial of service.
Why it explodes
The problem is (\w+,?)+. There's an unbounded quantifier + inside a group that is itself repeated with +. That means there are multiple distinct ways for the engine to divide the same input between iterations.
Given aaa, the group can match:
-
aaain one iteration -
aathena -
athenaa -
athenathena
Four ways to match three characters. The engine has to try all of them before it can conclude the string doesn't match, because the failing character is at the end. That's 2^(n-1) paths. At n=30 you're around 500 million. At n=40 you're past 500 billion and the heat death of the universe is a more realistic completion estimate than your request timeout.
The technical name is catastrophic backtracking. The security name is ReDoS — Regular Expression Denial of Service, CWE-1333.
The reason it survives review is that every failure mode is invisible from the code and from the happy path. The regex is short. It's readable. It has no obvious "hot spot." And it is instantaneous on every input that matches. You only see it on inputs that nearly match and then fail late — which is exactly the input an attacker sends and almost never the input a test suite contains.
The three shapes to look for
After staring at a lot of these, they mostly reduce to three patterns.
1. Nested unbounded quantifiers. A quantified group whose body can also repeat.
/(a+)+$/
/(\w+\s?)+$/
/^(\d+,)*\d+$/ // this one is fine, actually — see below
The third is safe because \d+, ends in a mandatory literal ,. That comma anchors each iteration, so there's exactly one way to split the input. The presence of nesting isn't sufficient on its own — ambiguity is what matters. Nesting is just the most common way to get it.
2. Ambiguous alternation inside a repeat. Branches that can match the same text.
/^(\w|\d)+$/
\d is a subset of \w. Every digit can be matched by either branch, so an n-digit string has 2^n paths through the group. This one hides better than nested quantifiers because there's no visible nesting at all.
3. Overlapping adjacent quantifiers.
/^\s*\s*$/
/^.*.*=.*$/
Two adjacent quantifiers over intersecting character classes. Any character matched by the first could equally have been matched by the second.
The advice you'll find online is wrong for JavaScript
Search for how to fix catastrophic backtracking and the top answer is almost always: wrap the inner group in an atomic group, (?>...), or use a possessive quantifier, a++. Atomic groups tell the engine "once you've matched this, never give the characters back," which kills the backtracking outright.
This is correct advice. It is also not available in JavaScript. JS regex has no atomic groups and no possessive quantifiers. /(?>a+)/ is a syntax error. /a++/ is a syntax error — + is not a valid quantifier target.
I found this out the annoying way: an early version of a rewrite suggester I wrote emitted a++ as its fix. It looked right, it matched the advice everyone gives, and it was unrunnable JavaScript. The corrected version has to separate the base atom from its own quantifier before recombining, because \w+ and \w are different things and you can't blindly append to the end of an arbitrary token.
The closest JS gets is an atomic-group emulation using a lookahead plus a backreference:
// atomic (?>\w+) emulated
/(?=(\w+))\1/
That works, but it is unreadable, and in most real cases you don't need it. The two fixes that actually apply:
Make the ambiguity impossible. Rewrite so there's exactly one way to split the input.
// before: /^(\w+,?)+$/
// after:
/^\w+(?:,\w+)*,?$/
Each iteration now starts with a mandatory ,. No ambiguity, linear time, same accepted language.
Or stop using a regex. For the comma-separated-words case:
const ok = s.length > 0 && s.split(",").every(p => /^\w*$/.test(p));
Uglier. Also unconditionally linear, and obviously correct to the next person who reads it. A lot of ReDoS bugs exist because a regex was reached for on reflex where a split would have done.
And regardless: bound your inputs. if (input.length > 256) return false; before the test caps the worst case no matter what the pattern does. It is not a fix — the exponent is still there, you've just chosen an exponent you can survive. Do it anyway. It's one line and it turns a total outage into a slow request.
Testing for this is trickier than it looks
The obvious approach is to run the regex against a generated attack string and time it. Don't do that in the process you care about — that's the same single-request DoS, self-inflicted. If you're going to time it, do it in a worker thread or a child process you can kill:
// crude but honest
const { Worker } = require("node:worker_threads");
const w = new Worker(`
const { workerData, parentPort } = require("node:worker_threads");
const t = Date.now();
new RegExp(workerData.src).test(workerData.input);
parentPort.postMessage(Date.now() - t);
`, { eval: true, workerData: { src: "^(\\\\w+,?)+$", input: "a".repeat(30) + "!" } });
const timer = setTimeout(() => { w.terminate(); console.log("VULNERABLE: timed out"); }, 2000);
w.on("message", ms => { clearTimeout(timer); console.log("ok:", ms, "ms"); });
Better still is to not execute the pattern at all. You can determine ambiguity by parsing the regex into an AST and checking the structural properties — whether a quantified node's body is nullable or self-overlapping, whether alternation branches have intersecting first-character sets, whether adjacent quantifiers overlap. That's static analysis, it terminates, and it never runs untrusted input through an engine with no timeout.
That's the approach I ended up taking. I wrote a small MCP server that does exactly this, because I wanted my own agent to be able to check a regex before writing it into a codebase rather than after: regex-safety-audit-mcp. It parses the pattern into a real hand-written AST — not string heuristics — flags nested unbounded quantifiers, ambiguous alternation inside repeated groups, and backreferences, and generates an attack string for you to test in your own sandboxed process. It never executes the pattern itself. A regex-safety tool that runs untrusted regexes to see if they're slow would be a fairly funny way to get owned.
Three tools: analyze_redos_risk, generate_attack_string, suggest_safe_rewrite. Free tier if you just want to throw a few patterns at it.
The part worth remembering
Grep your codebase for )+, )*, ){2,} — quantifiers applied to groups. Most of them are fine. The ones where the group's body ends in another unbounded quantifier are worth thirty seconds each.
And check the regexes you didn't write. Copy-pasted email validators are a famously rich source; the "RFC 5322 compliant" one that circulates on Stack Overflow has bitten multiple production systems. So has the default URL-matching regex in more than one popular library. The bug doesn't care that you got the pattern from a trustworthy-looking place.
If you've hit one of these in production, I'd genuinely like to hear which pattern did it — the catalog of real-world offenders is more useful than any general rule.
Top comments (0)