ReDoS : la regex d'email qui pouvait geler mon portfolio

Une regex de validation d'email en apparence anodine backtrackait en temps quadratique. Comment je l'ai mesurée, pourquoi elle explose, et le correctif en trois couches.

4 min de lecture#redos #regex #nodejs #appsec

Le formulaire de contact de ce portfolio validait les emails avec une regex que j'aurais pu écrire les yeux fermés. Une revue de code l'a signalée : avec la bonne chaîne, elle figeait le serveur entier pendant une trentaine de secondes. Un ReDoS, dans un portfolio de cybersécurité. Voilà comment je l'ai mesuré et corrigé.

La regex

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

Lue en diagonale, elle dit : quelque chose, une arobase, un domaine, un point, une extension d'au moins deux caractères. Le piège est dans la partie domaine : [^\s@]+ accepte aussi les points. Juste derrière, \. doit trouver un point, puis [^\s@]{2,} le reste. Quand la chaîne ne matche pas, le moteur essaie toutes les façons de répartir une suite de points entre ces trois morceaux.

Le mesurer

Pas besoin de deviner, ça se chronomètre. L'entrée piège : une arobase, n points, puis un espace pour que la regex échoue tout à la fin.

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

Le temps est multiplié par quatre quand l'entrée double : c'est quadratique. Pour chaque endroit où la partie domaine peut s'arrêter, le moteur fait reculer [^\s@]{2,} caractère par caractère avant d'échouer sur le $. En extrapolant, 200 000 points, soit à peine 200 Ko, prennent environ 27 secondes.

Pourquoi c'est grave ici

Node.js exécute le JavaScript sur un seul thread. Une regex qui tourne 27 secondes ne bloque pas une requête : elle bloque toutes les autres pendant ce temps, pages comprises. La limite de 3 messages par quart d'heure n'y changeait pas grand-chose, pour deux raisons : une seule requête suffit à figer le serveur, et on verra plus bas que cette limite se contournait.

Le correctif, en trois couches

1. Une regex sans ambiguïté

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

Les labels du domaine ([^\s@.]+) n'acceptent plus de point. Chaque point devient une frontière fixe entre deux labels, il n'existe plus qu'une seule façon de lire la chaîne. Sur la même entrée piège, le test rend la main en quelques microsecondes. Avec un cas plus vicieux, 100 000 labels b. suivis d'un espace, il faut 1,5 ms : le temps est redevenu linéaire.

2. Borner avant de matcher

Une regex linéaire sur une entrée illimitée reste une mauvaise idée. La route lit le corps en texte brut, refuse tout ce qui dépasse 16 000 caractères avec un 413, et vérifie la longueur de l'email (254 caractères au maximum) avant de lancer la 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. Une limite qu'on ne contourne pas en changeant un en-tête

La même revue a relevé un second problème. L'identifiant du client pour la limite de débit venait de la première entrée de X-Forwarded-For, c'est-à-dire de ce que le client envoie lui-même : en changeant cet en-tête à chaque requête, on repartait de zéro. La route prend maintenant l'entrée ajoutée par le proxy (la dernière), et applique en plus un plafond global :

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);

Ma première version comptait chaque requête dans ce plafond global : une rafale de requêtes malformées, chacune avec un en-tête différent, suffisait à bloquer le formulaire pour tout le monde. Il ne compte plus que les messages valides, et monte à 30 par heure.

Tout ça suppose un reverse proxy devant l'application, qui ajoute lui-même la vraie adresse du client à X-Forwarded-For. Sans proxy, l'en-tête entier vient du client, et seul le plafond global protège encore le formulaire.

À retenir

Devant une regex appliquée à une entrée utilisateur, je me pose maintenant deux questions : est-ce qu'un même morceau de texte peut être lu de plusieurs façons, et est-ce que la longueur est bornée avant le match ? Si c'est oui puis non, il y a probablement un ReDoS qui attend.

Retour au blog