ReDoS: cómo una expresión regular tumba tu servidor
Casi cualquier producto que procese texto del usuario acaba aceptando una expresión regular: filtros de correo, reglas de validación, buscadores, parsers de logs. Y casi siempre se asume que evaluar una regex es instantáneo.
No lo es. Hay expresiones de diez caracteres que, con la entrada correcta, tardan horas — y en Node.js eso no significa "esa petición va lenta", significa que el servidor entero deja de responder.
¿Qué es un ataque ReDoS?
ReDoS son las siglas de Regular expression Denial of Service: negación de servicio por expresión regular. El atacante no necesita volumen ni una botnet; le basta con mandar una cadena corta que haga trabajar al motor de regex durante una eternidad.
Es un ataque especialmente incómodo porque el texto malicioso no se ve malicioso. No hay comillas, ni <script>, ni nada que un filtro reconozca. Son treinta letras a seguidas de una X.
¿Por qué una expresión regular puede tardar horas?
Porque los motores de regex de JavaScript, Python, Java, Ruby y PHP usan backtracking: cuando un camino falla, retroceden y prueban el siguiente. Con la mayoría de las expresiones hay pocos caminos. Con un cuantificador dentro de otro cuantificador, los caminos se duplican por cada caracter.
El ejemplo canónico es /^(a+)+$/. El a+ interno puede quedarse con una letra, o con dos, o con todas; el + externo repite ese grupo. Para el texto aaaa eso son ocho formas distintas de repartir cuatro letras. Para treinta letras, más de quinientos millones.
/^(a+)+$/. El salto de 30 a 32 caracteres cuadruplica la espera.Fíjate en el detalle que hace daño: el texto que nunca coincide es el peor caso, no el que coincide. Si el texto encaja, el motor para en cuanto encuentra un reparto que sirve. Si no encaja, tiene que agotar la lista entera antes de poder decir "no".
¿Por qué esto es peor en Node.js que en otros lenguajes?
Node ejecuta tu código en un solo hilo. Mientras ese hilo está ocupado, no acepta conexiones, no responde peticiones, no corre temporizadores, no atiende el health check.
En un servidor con hilos por petición, una regex atascada arruina esa petición y deja al resto trabajando. En Node, una sola petición maliciosa congela a todos los usuarios a la vez. Es la diferencia entre un cliente molesto y una caída total.
Y como el proceso deja de responder al health check, en plataformas como Fly, Railway o Kubernetes el orquestador acaba reiniciando la máquina — con lo que se pierde también lo que estuviera en vuelo.
¿Cómo se cuela una regex peligrosa en un servicio de correo?
Casi nunca por un atacante externo. Las tres vías reales son más aburridas:
- Reglas escritas por el propio usuario. Cualquier producto con filtros de correo ("si el asunto coincide con…") termina ejecutando expresiones que escribió alguien más, contra cada mensaje que entra.
- Validaciones copiadas de internet. Las regex de email, URL o teléfono que circulan en foros son un catálogo de cuantificadores anidados. La regex de validación de email "completa" es un caso clásico documentado.
- Dependencias. Buena parte de los avisos de seguridad de npm cada año son ReDoS en librerías de parseo — de user-agents, de cabeceras, de fechas, de Markdown.
En los tres casos el patrón es el mismo: una expresión que nadie revisó se ejecuta contra texto que llega de fuera.
¿Sirve envolver la regex en un setTimeout?
No, y es la trampa más común. El código se ve razonable:
// NO funciona
const resultado = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), 50);
const r = re.test(texto); // ← bloquea aquí
clearTimeout(timer);
resolve(r);
});
La intención es "corta a los 50 ms". Lo que realmente pasa es que re.test() es síncrono: mientras corre, el event loop está bloqueado y el setTimeout no puede dispararse. El temporizador solo llega a ejecutarse cuando la regex ya terminó — o sea, siempre tarde, y siempre inútil.
Con la regex de arriba y treinta caracteres, ese "guard de 50 ms" devuelve su resultado a los 20 segundos. No cortó nada; solo dio la sensación de que había una protección.
El mismo razonamiento invalida cualquier variante: Promise.race, AbortController o un async alrededor. Ninguno puede interrumpir código síncrono, porque todos necesitan que el event loop esté libre para actuar.
¿Cómo se protege de verdad?
Tres defensas, de la más barata a la más definitiva. Se pueden combinar.
1. Validar la expresión al guardarla
Es lo más barato y ataja la mayoría de los casos reales. Cuando el usuario guarda su regla, se revisa la expresión — una vez, no en cada correo — y se rechaza si tiene la forma peligrosa:
- Un cuantificador dentro de otro:
(a+)+,(a*)*,(a+)*. - Alternancias que se solapan y se repiten:
(a|a)+,(\d|\w)+. - Expresiones absurdamente largas.
En el mismo paso conviene limitar el texto de entrada. Si nunca evalúas más de 200 caracteres, el peor caso queda acotado por construcción — y en un correo, el asunto y el remitente ya son cortos por naturaleza.
2. Evaluar en un worker thread
Si necesitas admitir expresiones arbitrarias, la regex tiene que correr donde sí se pueda matar. Un Worker de Node se termina con worker.terminate() desde el hilo principal, que sigue libre para contar el tiempo.
Cuesta unos milisegundos por evaluación y algo de complejidad, así que tiene sentido cuando la regex es de verdad impredecible.
3. Usar un motor sin backtracking
La solución de fondo. RE2 (en Node, el paquete node-re2) garantiza tiempo lineal: nunca retrocede, así que el ReDoS deja de existir como categoría. Se paga con una dependencia nativa y con renunciar a algunas construcciones — lookbehind, backreferences — que casi ninguna regla de usuario usa.
¿Cómo saber si tus expresiones son peligrosas?
Una revisión rápida, en orden:
- Busca cuantificadores anidados en tu código:
grep -rn "+)+\|\*)\*\|+)\*" .Casi todo ReDoS real tiene esa forma. - Pregunta de dónde viene el texto. Una regex peligrosa contra una constante del código es inofensiva; contra el asunto de un correo, no.
- Mídelo. Genera la entrada del peor caso —el prefijo que casi coincide más un caracter que rompe— y cronometra. Si el tiempo se cuadruplica al añadir dos caracteres, ya tienes la respuesta.
- Corre
npm audity lee los avisos de ReDoS en dependencias en vez de saltártelos.
Qué hace MailMask con esto
MailMask deja crear reglas por dominio que se evalúan contra cada correo que entra, así que esto no es para nosotros una curiosidad académica sino parte del trabajo. Aplicamos las dos primeras defensas de esta lista: la expresión se revisa al guardarse —si trae cuantificadores anidados, la regla no se crea y te decimos por qué— y el texto que se evalúa va acotado, de modo que el peor caso queda limitado por construcción.
La lección general que nos llevamos es más aplicable que el detalle técnico: una protección que nadie midió no es una protección. El guard con setTimeout es el mejor ejemplo — se lee perfecto en la revisión de código, tranquiliza a quien lo escribe y no hace absolutamente nada. La única forma de saberlo es correrlo con la entrada mala y ver el reloj.
Si te interesa cómo tratamos el correo entrante, sigue con qué es el email forwarding o con DKIM, SPF y DMARC explicados.
Resumen rápido
- ReDoS = una regex con cuantificadores anidados tarda tiempo exponencial contra texto que casi coincide.
- En Node congela el proceso entero, no solo la petición del atacante.
- El truco del
setTimeoutno sirve: no se puede interrumpir código síncrono desde el mismo hilo. - Lo que sí sirve: validar la expresión al guardarla y acotar la entrada; un worker thread que se pueda matar; o RE2.