← Todos los artículos

ReDoS: cómo una expresión regular tumba tu servidor

📅 21 Ago 2026 ⏱ 8 min de lectura

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.

El motor de regex prueba todos los repartos posibles del texto entre los dos cuantificadores anidados; como el texto termina en X nunca puede coincidir, así que agota la lista completa antes de rendirse, y esa lista se duplica con cada caracter añadido. LA ENTRADA REPARTOS QUE PRUEBA EL RESULTADO aaaaX contra /^(a+)+$/ Nunca va a coincidir La X final rompe cualquier reparto posible. Pero el motor tiene que probarlos todos. (aaaa) falla (aaa)(a) falla (aa)(aa) falla (aa)(a)(a) falla (a)(a)(a)(a) falla …y así con cada reparto restante cada letra de más duplica la lista 20 letras 23 ms 30 letras 4.6 s 32 letras 18 s 40 letras ~1.3 h y mientras tanto Node no atiende a nadie
Los tiempos son reales, medidos en Node 22 sobre /^(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:

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.

Comparación entre el guard con setTimeout, que corre la regex en el mismo hilo y por eso no puede cortarla, y la evaluación en un worker thread, que sí se puede terminar al vencer el tiempo límite. NO PROTEGE SÍ PROTEGE Hilo principal setTimeout(cortar, 50) ← queda en cola re.test(texto) ← bloquea el hilo nada más puede correr aquí dentro El temporizador espera su turno y sólo corre cuando la regex ya acabó Resultado medido guard de 50 ms → devolvió a los 20 000 ms Hilo principal manda el texto al worker arranca su temporizador sigue atendiendo peticiones Worker thread corre la regex; se puede terminar a la fuerza Al vencer el plazo worker.terminate() y la regla se marca inválida
La diferencia no es el temporizador: es quién corre la regex. Un temporizador solo sirve si hay alguien libre para atenderlo.

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:

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:

  1. Busca cuantificadores anidados en tu código: grep -rn "+)+\|\*)\*\|+)\*" . Casi todo ReDoS real tiene esa forma.
  2. 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.
  3. 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.
  4. Corre npm audit y 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

Reglas de correo que no tumban nada

Filtra, reenvía y responde desde tu dominio. Desde $49 MXN/mes.

Crear cuenta gratis