A 31-character name froze this Node server for 6 seconds
Here's a signup check. A display name is words with single spaces between them. const NAME_RE = /^(\w+\s?)*$/; "Ada Lovelace" passes. "Linus" passes. Each request takes about 3 ms. Then someone signs up as aaaa
Here's a signup check. A display name is words with single spaces between them.
const NAME_RE = /^(\w+\s?)*$/;
"Ada Lovelace" passes. "Linus" passes. Each request takes about 3 ms.
Then someone signs up as aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!. That's 30 a's and an exclamation mark.
The server takes 5.7 seconds to say "invalid name". And for those 5.7 seconds, it doesn't answer anyone else.
The test
A plain Node server:
import http from "node:http";
const NAME_RE = /^(\w+\s?)*$/;
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/health") return res.end("ok");
const name = url.searchParams.get("name") ?? "";
res.end(NAME_RE.test(name) ? "welcome" : "invalid name");
}).listen(3000);
And a client in a separate process that hits /health every half second, like a load balancer would, then sends the bad name:
1.5s /health answered in 3 ms
2.0s /signup?name=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!
7.7s -> "invalid name" after 5696 ms
2.5s /health answered in 5200 ms
3.0s /health answered in 4698 ms
3.5s /health answered in 4197 ms
...
7.0s /health answered in 698 ms
7.5s /health answered in 198 ms
8.0s /health answered in 5 ms
Every health check sent during those seconds just waited. Node runs your JavaScript on one thread, and a regex match doesn't stop halfway to let other code run. No await, no break. One request, 31 bytes, and the whole process is stuck.
Every extra character doubles it
Same regex, on Node 24, this machine:
| Input | Time |
|---|---|
| 21 characters | 5.6 ms |
| 25 characters | 90 ms |
| 27 characters | 352 ms |
| 29 characters | 1.4 s |
| 31 characters | 5.7 s |
Two more characters, four times slower. At 41 characters it would be about an hour and a half.
Why
JavaScript's regex engine backtracks. When there's more than one way to match something, it picks one. If that fails later, it goes back and tries the next one.
(\w+\s?)* means "one or more words, each with an optional space after it". The space is optional, so aaaa can be one word. Or aaa and a. Or aa and aa. Or a, a, a, a. Every gap between two letters is either a word break or not. 30 letters have 29 gaps, so that's 2^29 ways. Over 500 million.
On a normal name, the first way works and the engine stops right there. But with the ! at the end, the $ fails every time. The engine can only say "no match" after it has tried every single split.
Here it is with 4 a's. The widget uses (a+)+, which is the same shape without the space:
8 ways for 4 a's. 16 for 5. One more letter, twice the work.
The shape to look out for: a repeated group, with something inside it that also repeats, so the same text can be split more than one way. (a+)+, (\w+\s?)*, (a|aa)+ are all the same problem.
The fix
Write it so each character can only be matched one way:
const NAME_RE = /^(\w+\s)*\w*$/;
Now every repeated word has to end with a space. A run of letters can only be read one way. The same 31 characters: 0.003 ms.
Does it accept exactly the same names? I tested every string up to 9 characters made from a, b, space, tab and !. That's 2.4 million strings, and the two regexes agreed on all of them.
Other things that help:
- Check the length first. A display name doesn't need to be 500 characters. That won't save you here, since 31 is short. But most ReDoS bugs in real code grow slower (n² instead of 2āæ), and for those a length limit is enough.
-
Let a linter catch it.
eslint-plugin-regexphas a rule calledno-super-linear-backtrackingthat flags these patterns before they ship. -
Use an engine that doesn't backtrack, especially if users can type their own patterns. Go's
regexpand there2package on npm never backtrack. V8 has one too, behind a flag: withnode --enable-experimental-regexp-engine-on-excessive-backtracks, the same 31 characters took 1.2 ms. It's experimental and off by default, so I wouldn't build on it.
It has happened to big sites
Stack Overflow was down for about 34 minutes in July 2016. A regex that trimmed whitespace hit a post with around 20,000 spaces in a row. Cloudflare had a 27-minute outage in July 2019 because of one regex in a firewall rule. Both wrote public postmortems, and both are worth reading.
One weird thing
The first time I ran this, the 31 characters took 41 seconds, not 5.7.
V8 runs a new regex through a slower interpreter first, and only compiles it to machine code once it's been used. My first test sent the bad name before any normal one, so it ran cold. With 27 characters: 2.4 s cold, 0.35 s warm. So a server that just restarted is the easiest one to freeze.
The thing to remember
If a regex runs on input you don't control, ask: can the same characters be matched in more than one way? If yes, and something after it can fail, one short string can stop your server.
You can drag the input size yourself and watch the step count blow up: https://software-atlas.haiduc0147.dev/t/regular-expressions#catastrophic
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes ā full credit and traffic to the original publisher.
