ReDoS: the email regex that could freeze my portfolio

An innocent-looking email validation regex backtracked in quadratic time. How I measured it, why it blows up, and the three-layer fix.

4 min read#redos #regex #nodejs #appsec

This portfolio's contact form validated emails with a regex I could have written with my eyes closed. A code review flagged it: given the right string, it froze the whole server for about thirty seconds. A ReDoS, in a cybersecurity portfolio. Here is how I measured it and fixed it.

The regex

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

At a glance it reads: something, an at sign, a domain, a dot, an extension of at least two characters. The trap is in the domain part: [^\s@]+ also accepts dots. Right after it, \. needs a dot, then [^\s@]{2,} takes the rest. When the string doesn't match, the engine tries every way of splitting a run of dots between those three pieces.

Measuring it

No need to guess, it can be timed. The trap input: an at sign, n dots, then a space so the regex fails at the very end.

const OLD = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
 
const time = (re, input) => {
  const t = process.hrtime.bigint();
  re.test(input);
  return Number(process.hrtime.bigint() - t) / 1e6;
};
 
for (const n of [2000, 4000, 8000, 16000]) {
  console.log(n, time(OLD, "a@" + ".".repeat(n) + " ").toFixed(1), "ms");
}
2000   2.5 ms
4000   10.9 ms
8000   42.4 ms
16000  172.9 ms

Time is multiplied by four when the input doubles: it's quadratic. For every place where the domain part could stop, the engine walks [^\s@]{2,} back one character at a time before failing on $. Extrapolating, 200,000 dots, barely 200 KB, take about 27 seconds.

Why it matters here

Node.js runs JavaScript on a single thread. A regex that runs for 27 seconds doesn't block one request: it blocks every other request meanwhile, pages included. The limit of 3 messages per quarter hour didn't change much, for two reasons: a single request is enough to freeze the server, and, as we'll see below, that limit could be bypassed.

The fix, in three layers

1. A regex without ambiguity

export const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)*\.[^\s@.]{2,}$/;

Domain labels ([^\s@.]+) no longer accept dots. Every dot becomes a fixed boundary between two labels, so there is only one way left to read the string. On the same trap input, the test returns within a few microseconds. With a nastier case, 100,000 b. labels followed by a space, it takes 1.5 ms: time is linear again.

2. Bound before matching

A linear regex on unbounded input is still a bad idea. The route reads the body as raw text, rejects anything over 16,000 characters with a 413, and checks the email length (254 characters at most) before running the regex:

const raw = await request.text();
if (raw.length > MAX_BODY_CHARS) {
  return NextResponse.json({ error: "Message too large." }, { status: 413 });
}
 
// ...
 
if (cleanEmail.length > LIMITS.email || !EMAIL_RE.test(cleanEmail)) {
  return badRequest("Invalid email address.");
}

3. A limit you can't dodge by changing a header

The same review found a second problem. The client identifier used for rate limiting came from the first entry of X-Forwarded-For, which is whatever the client sends: rotating that header on every request started the count from zero. The route now takes the entry appended by the proxy (the last one), and adds a global ceiling on top:

const perClient = checkRateLimit(`contact:${getClientId(request)}`, PER_CLIENT);
if (!perClient.allowed) return tooMany(perClient.retryAfterSec);
// Reject early once the ceiling is reached, without spending it
const full = isRateLimited("contact:global", GLOBAL);
if (!full.allowed) return tooMany(full.retryAfterSec);
 
// ... read the body, honeypot, validation ...
 
// Only a valid message counts against the site-wide ceiling
const global = checkRateLimit("contact:global", GLOBAL);
if (!global.allowed) return tooMany(global.retryAfterSec);

My first version counted every request against that global ceiling: a burst of malformed requests, each with a different header, was enough to lock the form for everyone. It now only counts valid messages, and goes up to 30 per hour.

All of this assumes a reverse proxy in front of the app that appends the client's real address to X-Forwarded-For itself. Without a proxy, the whole header comes from the client, and only the global ceiling still protects the form.

Takeaway

When a regex runs on user input, I now ask two questions: can the same piece of text be read in more than one way, and is the length bounded before the match? If the answers are yes and no, there is probably a ReDoS waiting.

Back to the blog