1

‼️ Uma regex pode congelar seu Node.js enquanto passa nos testes ‼️

Uma única regex levou as CPUs que serviam HTTP e HTTPS na Cloudflare a quase 100% no mundo inteiro.

O incidente durou 27 minutos. A regra tinha passado por revisão e pelo CI.

Ela reconhecia os textos certos. O problema era o tempo absurdo que levava para rejeitar um texto errado.

Essa diferença parece detalhe acadêmico até aparecer numa API Node.js. Como RegExp.test() roda de forma síncrona na thread do event loop, uma entrada pequena o bastante para caber numa requisição pode deixar todos os outros clientes esperando. É o cenário de Regular Expression Denial of Service, ou ReDoS.

A regra prática deste texto é simples: regex que recebe dado externo precisa ser testada em dois eixos — resposta correta e custo para chegar nela.

O teste feliz esconde a explosão

Imagine um endpoint que aceita tags separadas por vírgula. Alguém escreve este validador:

const tags = /^(\w+,?)*$/;

tags.test("node,seguranca,regex"); // true
tags.test("node,,regex");         // false

Os exemplos comuns passam. Há âncoras no começo e no fim, a classe de caracteres parece restrita e nem existe aquele .* que costuma chamar atenção no review.

Agora vamos medir uma entrada inválida só no último caractere:

import { performance } from "node:perf_hooks";

const vulneravel = /^(\w+,?)*$/;

for (const n of [18, 20, 22, 24, 26]) {
  const entrada = "a".repeat(n) + "!";
  const inicio = performance.now();
  vulneravel.test(entrada);
  console.log(n, (performance.now() - inicio).toFixed(1), "ms");
}

No meu teste com Node 22.16, as quatro últimas medições ficaram perto de 4,6 ms, 17,6 ms, 67,8 ms e 265,4 ms. O número exato varia de máquina para máquina. O formato da curva é o que importa: ao acrescentar dois a, o tempo ficou aproximadamente quatro vezes maior.

O ! no final obriga o motor a provar que não existe match. Antes de desistir, ele tenta redistribuir os mesmos a entre várias execuções do grupo repetido.

Com poucos caracteres já perdemos centenas de milissegundos. Aumentar um pouco a entrada transforma validação em indisponibilidade.

O motor está percorrendo uma árvore de escolhas

O V8 normalmente executa regex com o Irregexp, um motor baseado em backtracking. Quando há mais de um caminho possível, ele escolhe um. Se algum trecho posterior falhar, volta até a última decisão e tenta outra combinação.

Esse comportamento dá poder às regex. Recursos como lookaround e backreference dependem dele, e os casos normais costumam ser rápidos. A armadilha aparece quando há escolhas sobrepostas dentro de uma repetição.

Para o grupo da nossa regex, aaaa pode ter várias divisões equivalentes:

aaaa
aaa + a
aa + aa
aa + a + a
a + aaa
a + aa + a
...

Como o ! final invalida todas, o motor percorre a árvore inteira. O exemplo clássico da OWASP deixa o crescimento visível: ^(a+)+$ encontra 16 caminhos para aaaaX, mas 65.536 caminhos quando são dezesseis a antes do X. Cada a extra dobra a quantidade de tentativas.

É também por isso que medir apenas entradas válidas não serve. A documentação do Node.js alerta que uma regex vulnerável pode aceitar um texto longo rapidamente; a explosão costuma acontecer na falha tardia, quando ainda há muitos caminhos a descartar.

Nem toda repetição aninhada será lenta em todo motor. Runtimes reconhecem alguns formatos e os otimizam. Só que “o V8 atual otimizou meu exemplo” não é garantia de complexidade. Se dois trechos repetidos conseguem consumir os mesmos caracteres, vale reescrever e testar no runtime usado em produção.

A correção boa remove a ambiguidade

A gramática das tags pode ser descrita sem sobreposição: uma palavra, seguida de zero ou mais pares formados por vírgula e palavra.

const tagsSeguras = /^\w+(?:,\w+)*$/;

tagsSeguras.test("node,seguranca,regex"); // true
tagsSeguras.test("node,,regex");         // false

Agora cada caractere tem uma função. A vírgula só inicia o próximo par; os caracteres da palavra não ficam passeando entre repetições equivalentes. No mesmo teste local, essa versão ficou abaixo da resolução útil da medição curta.

Alguns sinais bons para procurar no review:

Formato suspeitoO riscoDireção mais segura
(x+)+ ou (x*)*laços interno e externo repartem o mesmo textodeixar uma única repetição
`(aaa)+`alternativas compartilham prefixo
.*.*TOKENcuringas vizinhos trocam caracteresusar classe limitada ou delimitador
backreference em entrada externapior caso difícil de limitarevitar, limitar e isolar a execução

Trocar quantificador guloso por preguiçoso não resolve a estrutura. No postmortem da Cloudflare, usar *? melhorou um caso que dava match, mas não mudou o custo do caso catastrófico que falhava. A ordem dos caminhos mudou; a quantidade de caminhos continuou lá.

Produção precisa de mais de uma barreira

O primeiro reforço é limitar a entrada antes da regex. Se o campo aceita no máximo 200 caracteres, rejeite 20 mil bytes logo no começo. O limite não transforma algoritmo exponencial em linear, mas reduz o estrago máximo e ainda protege logs, parser e banco.

O segundo é trocar regex por uma operação mais simples quando ela expressa melhor a regra. Para a lista de tags, dá para separar e validar cada parte:

function listaDeTagsValida(valor) {
  if (valor.length === 0 || valor.length > 200) return false;

  const partes = valor.split(",");
  return partes.length <= 20 &&
    partes.every((parte) => /^\w+$/.test(parte));
}

Ficou algumas linhas maior, mas o limite de 20 tags virou regra explícita e cada regex pequena tem uma repetição sem ambiguidade. Para busca literal, includes() ou indexOf() costuma ser ainda mais direto e tem custo linear.

O terceiro reforço fica nos testes. Além dos casos válidos e inválidos curtos, inclua quase acertos longos: prefixo válido com um caractere proibido no final, delimitador ausente, prefixos repetidos e valor no tamanho máximo permitido. A equipe da Mozilla recomenda justamente testar entradas grandes que não dão match, porque os problemas podem aparecer só com carga.

Um analisador estático de ReDoS no CI ajuda a localizar padrões suspeitos. Ele não substitui o teste: sintaxe, recursos do motor e entrada interagem, então ausência de alerta não prova tempo linear.

Timeout em Promise não interrompe regex síncrona

Este detalhe derruba uma correção comum:

await Promise.race([
  Promise.resolve(regex.test(entrada)),
  timeout(100),
]);

O timer não consegue disparar enquanto regex.test() estiver usando a thread do event loop. O Promise.race() só observa o atraso depois que a regex devolve o controle.

Para executar padrões realmente complexos ou fornecidos pelo usuário, a barreira precisa estar fora daquela thread: Worker, processo separado ou serviço isolado que possa ser encerrado ao estourar o orçamento. Também vale medir event-loop delay, CPU, tamanho das entradas e latência por validador. Um span chamado validacao_regex_email conta uma história muito melhor que apenas “requisição levou 3 segundos”.

O V8 possui um motor experimental sem backtracking que oferece tempo linear e pode assumir depois de 50 mil backtracks. Não é rede de proteção universal. O fallback exclui padrões com backreference, lookaround, certas repetições finitas e flags u ou i; o próprio V8 diz que o motor linear pode ser ordens de grandeza mais lento nos casos comuns.

Ou seja: não terceirize a segurança do seu padrão para uma otimização do runtime.

A pergunta certa no review

No incidente da Cloudflare, a regex não foi a única falha: houve rollout global, barreiras de CPU insuficientes e demora no rollback. Mesmo assim, uma regra de WAF com backtracking excessivo iniciou a sequência, deixou a rede fora por 27 minutos e, em certo momento, derrubou cerca de 80% do tráfego.

Uma versão menor pode estar hoje num formulário de cadastro, rota, parser de log ou dependência do seu projeto.

Quando a regex recebe dado externo, eu guardaria quatro perguntas:

  1. Partes repetidas conseguem consumir os mesmos caracteres?
  2. Um quase acerto longo falha rápido?
  3. O tamanho foi limitado antes do match?
  4. Uma execução lenta bloqueia trabalho compartilhado?

Se for para lembrar de uma coisa: teste o caminho da rejeição. Uma regex que responde false depois de 30 segundos não está correta.

Qual foi a regex mais traiçoeira que você já precisou desmontar em produção?

Carregando publicação patrocinada...