Regex que aceita lixo: como testar com exemplos que passam e que têm de falhar
Escreva a tabela de casos antes da expressão: o que deve passar, o que deve falhar. Ancore, limite o tamanho e evite o padrão que trava o servidor.
A validação de slug do painel aceitou backup-mysql, você colou no código e foi almoçar. À tarde, alguém cadastrou <script>backup, e a expressão disse sim de novo. A resposta curta: uma regex só está testada quando você tem exemplos que devem passar e exemplos que devem falhar, rodando automaticamente. Escreva primeiro a tabela de casos (vazio, longo demais, caractere proibido, quebra de linha), ancore o começo e o fim quando a string inteira tem de bater, limite o tamanho antes de aplicar a expressão e evite quantificadores aninhados sobre entrada do usuário.
O exemplo deste guia é a validação de slugs de URL de um blog: letras minúsculas sem acento, números e hífens simples entre as partes, de 3 a 80 caracteres.
Por que a regex que passou no teste aceita lixo
Quem escreve uma expressão testa o caso que tem em mente. Isso só prova que ela aceita o que você já esperava. A pergunta que importa é o que mais ela aceita, e para isso é preciso escrever os casos ruins de propósito.
A causa mais comum é a falta de âncora. Sem ^ e $, a expressão procura um trecho que case em qualquer lugar da string:
/[a-z0-9-]+/.test('<script>backup') // true: achou "script"
/^[a-z0-9-]+$/.test('<script>backup') // false
A segunda causa é a classe frouxa: [a-z0-9-]+ aceita ---, -backup e backup--mysql. A terceira é esquecer limites: a expressão não diz nada sobre tamanho, e um slug de 5 mil caracteres passa.
Como escrever a regex com a tabela de casos
- Escreva a regra em português. "Partes de letras minúsculas e números, separadas por um único hífen, sem hífen no começo nem no fim, de 3 a 80 caracteres."
- Liste pelo menos três textos que devem passar, incluindo os limites (o mais curto e o mais longo permitidos).
- Liste os que devem falhar: vazio, curto e longo demais, maiúscula, acento, espaço, hífen duplo, hífen nas pontas, quebra de linha.
- Escreva a expressão ancorada.
- Limite o tamanho fora da regex, com uma comparação simples, antes de aplicá-la.
- Rode os casos como teste automatizado, que entra no CI junto com o resto do código.
- Repita a mesma regra no servidor, não só no campo do formulário.
A validação
// slug.js
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function slugValido(texto) {
return typeof texto === 'string'
&& texto.length >= 3
&& texto.length <= 80
&& SLUG.test(texto);
}
module.exports = { slugValido };
A estrutura [a-z0-9]+(?:-[a-z0-9]+)* diz exatamente a regra: uma parte, seguida de zero ou mais grupos "hífen e parte". Hífen duplo e hífen nas pontas ficam impossíveis sem nenhuma exceção extra.
Os testes
// slug.test.js
const test = require('node:test');
const assert = require('node:assert');
const { slugValido } = require('./slug');
const devemPassar = ['abc', 'backup-de-mysql', 'php8-no-docker', 'a'.repeat(80)];
const devemFalhar = [
'', 'ab', 'a'.repeat(81),
'Backup', 'ação', 'backup mysql',
'backup--mysql', '-backup', 'backup-',
'<script>backup', 'backup\nmysql', null
];
for (const s of devemPassar) {
test('passa: ' + JSON.stringify(s).slice(0, 30), () => assert.ok(slugValido(s)));
}
for (const s of devemFalhar) {
test('falha: ' + JSON.stringify(s).slice(0, 30), () => assert.ok(!slugValido(s)));
}
Rode com node --test. Cada caso vira uma linha no relatório, e um caso que deveria falhar e passou aponta direto para o buraco. Ferramentas como o regex101 ajudam a montar e entender a expressão com textos de exemplo; o que garante que ninguém a quebre depois é o teste no repositório.
As armadilhas que a tabela pega
Quebra de linha e o significado de $
Em JavaScript sem a flag m, o $ casa só no fim da string. Em Python, o $ também casa antes de uma quebra de linha final, então re.match(r'^[a-z]+$', 'abc\n') encontra correspondência. Em Python, use re.fullmatch:
import re
SLUG = re.compile(r'[a-z0-9]+(?:-[a-z0-9]+)*')
def slug_valido(texto: str) -> bool:
return 3 <= len(texto) <= 80 and SLUG.fullmatch(texto) is not None
A flag m (multilinha) muda o sentido de ^ e $ para começo e fim de cada linha. Com ela, 'ok\n<script>' passaria numa expressão ancorada. Só use quando o objetivo é mesmo trabalhar linha a linha.
A flag g e o estado escondido
Em JavaScript, uma regex com a flag g guarda a posição da última busca em lastIndex, e test recomeça dali:
const NUM = /^\d+$/g;
NUM.test('123'); // true
NUM.test('123'); // false: começou da posição 3
Para validação, não use g. O bug aparece como "a validação falha uma vez sim, outra não".
A expressão que trava o servidor
Motores de regex com retrocesso, como os de JavaScript, Python, PHP, Java e .NET por padrão, testam caminhos alternativos quando uma tentativa falha. Com quantificadores aninhados sobre os mesmos caracteres, o número de caminhos explode:
/^(a+)+$/.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!');
Com trinta letras e um caractere final que impede o casamento, essa linha pode levar segundos, e cada letra a mais aproximadamente dobra o tempo. Numa API, uma única requisição com esse texto prende o processo. É o chamado ReDoS. A defesa tem camadas: limitar o tamanho antes da regex, evitar (x+)+ e alternativas que se sobrepõem, como (a|a)*, e, quando a plataforma oferece, usar um motor sem retrocesso, como o RE2, ou um tempo limite. No .NET, o construtor de Regex aceita um tempo limite, e as versões recentes têm a opção RegexOptions.NonBacktracking.
Validar e-mail com regex
Se a regra real é "tem uma arroba e um domínio", escreva isso e não finja que validou e-mail. A única validação que prova um endereço é mandar uma mensagem com link de confirmação. Uma regex de e-mail enorme copiada da internet costuma recusar endereços válidos e, às vezes, trazer o padrão de retrocesso descrito acima. Para campos numéricos, o caso específico está em campo numérico: a máscara não é a validação.
Erros comuns
- Testar só o caso feliz. A expressão aceita lixo e ninguém descobre até ele aparecer no banco.
- Esquecer as âncoras. Basta um pedaço válido no meio da string para a validação dizer sim.
- Usar
$em Python esperando o fim exato. Uma quebra de linha final passa e vai parar num nome de arquivo ou num cabeçalho. - Usar a flag
gem validação. O resultado alterna entre verdadeiro e falso para o mesmo texto. - Quantificador aninhado sobre entrada do usuário. Uma string preparada consome a CPU e derruba a API.
- Validar só no navegador. Quem chama a API direto ignora o campo; veja validação de plugin não basta.
Checklist
- A regra está escrita em português ao lado da expressão.
- Existem pelo menos três casos que passam e cinco que falham.
- Os casos incluem vazio, limites de tamanho, maiúscula, acento e quebra de linha.
- A expressão é ancorada, ou usa
fullmatchem Python. - O tamanho é limitado antes da regex.
- Não há quantificador aninhado sobre os mesmos caracteres.
- Os casos rodam como teste automatizado no CI.
- A mesma regra existe no servidor.
Pegue a regex de validação mais importante do seu sistema e rode nela uma string vazia, uma com quebra de linha no fim e uma com 10 mil caracteres. Ela recusa as três, e essa mesma validação existe no servidor?
Referência: https://regex101.com/