Here is an example of JavaScript code that is vulnerable to regular expression denial of service:
🥺 Vulnerable Code
// Vulnerable: nested quantifiers cause catastrophic backtracking
const EMAIL = /^([a-zA-Z0-9_.-]+)+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
app.post("/api/subscribe", (req, res) => {
if (!EMAIL.test(req.body.email)) {
return res.status(400).json({ error: "Invalid email" });
}
return subscribe(req.body.email).then(() => res.json({ subscribed: true }));
});([a-zA-Z0-9_.-]+)+ gives the engine an exponential number of ways to split the same input. A string of forty a characters with a trailing ! and no @ forces the backtracking engine through billions of attempts. Node runs your handlers on one thread, so a single request of a few dozen bytes freezes the entire process for every other user.
😎 Secure Code
Here is a version of the same code that is secured against regular expression denial of service:
// Linear-time validation: bounded lengths, no nested quantifiers
const EMAIL = /^[a-zA-Z0-9_.+-]{1,64}@[a-zA-Z0-9.-]{1,185}$/;
app.post("/api/subscribe", express.json({ limit: "8kb" }), (req, res) => {
const email = String(req.body.email || "");
if (email.length > 254 || !email.includes(".") || !EMAIL.test(email)) {
return res.status(400).json({ error: "Invalid email" });
}
return subscribe(email).then(() => res.json({ subscribed: true }));
});Every quantifier is bounded and none of them nest, so matching is linear in the length of the input, and the length is capped before the regex ever runs. When a format genuinely needs a complex pattern, use a real parser or a linear-time engine such as the re2 bindings, and run eslint-plugin-security or a ReDoS checker in CI to catch (x+)+ and (a|a)* shapes before they ship.