Programação Por Equipe DevBras

Capa do artigo: Regex que aceita lixo: como testar com exemplos que passam e que têm de falhar

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

  1. 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."
  2. Liste pelo menos três textos que devem passar, incluindo os limites (o mais curto e o mais longo permitidos).
  3. 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.
  4. Escreva a expressão ancorada.
  5. Limite o tamanho fora da regex, com uma comparação simples, antes de aplicá-la.
  6. Rode os casos como teste automatizado, que entra no CI junto com o resto do código.
  7. 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 g em 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 fullmatch em 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?

Avalie este artigo

Referência: https://regex101.com/