Programação Por Equipe DevBras

Capa do artigo: Regex que só aceita número: por que "12a" ainda entra e como validar no servidor

Regex que só aceita número: por que "12a" ainda entra e como validar no servidor

Escreva a regex ancorada certa, separe quantidade, código com zeros e valor decimal, converta só depois de validar e prove com um curl que a API recusa "12a".

O campo de quantidade da loja tinha máscara: digitou letra, ela sumia. Mesmo assim, apareceu no banco um pedido com 1.000 unidades de um cabo que custa R$ 39,90. Ninguém digitou 1000. Um integrador mandou "1e3" direto na API, o servidor fez Number("1e3") e recebeu mil. No mesmo dia, um CEP 01310-100 foi gravado como o número 1310100 e a etiqueta de envio saiu sem o zero.

A resposta curta: a máscara do navegador é conforto, a validação é no servidor, e "só número" não é um tipo. Decida o que o campo é (quantidade inteira, código com zeros à esquerda ou valor decimal), valide o texto com uma regex ancorada no início e no fim, como ^[0-9]{1,3}$, e só converta depois de validar. Código como CEP e CPF continua sendo texto.

Por que a máscara não protege o dado

Tudo que roda no navegador está nas mãos de quem usa. A pessoa pode colar texto, desativar o JavaScript, usar uma extensão que preenche o formulário ou, mais comum, nem passar pelo formulário: um aplicativo, um integrador ou um script chama a API direto. A máscara não participa dessas conversas.

No servidor, a armadilha seguinte é a conversão permissiva. Em JavaScript, estas linhas são todas verdadeiras:

parseInt('12a')   // 12   (para no primeiro caractere inválido)
Number('')        // 0    (vazio vira zero)
Number('  12  ')  // 12   (espaços são ignorados)
Number('1e3')     // 1000 (notação científica)
Number('0x10')    // 16   (hexadecimal)

Se o servidor converte antes de validar, "12a" vira 12, campo vazio vira 0 e "1e3" vira 1000. Nenhum desses é o que a pessoa quis dizer, e nenhum gera erro. É por isso que a ordem importa: primeiro confira o formato do texto, depois converta.

A regex certa depende do que o campo é

"Só número" junta três coisas diferentes:

  • Quantidade é um inteiro com faixa: de 1 a 999, por exemplo. Zero à esquerda não importa, sinal não é permitido.
  • Código (CEP, CPF, matrícula, número de conta) é texto feito de dígitos. Tem tamanho fixo, o zero à esquerda faz parte, e você nunca faz conta com ele. Guarde como texto.
  • Valor decimal (preço, peso) tem separador. No Brasil, a pessoa digita vírgula. Dinheiro vai para centavos inteiros, como explica dinheiro em centavos.

Âncoras: o erro que deixa "12a" passar

A regex [0-9]+ sem âncoras procura dígitos em algum lugar do texto. Em "12a" ela encontra "12" e responde que sim. Com ^ no início e $ no fim, o texto inteiro precisa ser dígito.

Há uma pegadinha entre linguagens. Em JavaScript, sem a flag m, o $ só casa no fim do texto. Em PHP (PCRE) e em Python, o $ também aceita uma quebra de linha final, então "12\n" passa. Em PHP, use \z no lugar de $ ou o modificador D; em Python, use re.fullmatch.

Outra: \d em JavaScript é só 0 a 9, mas em .NET e no re do Python 3 ele aceita dígitos de outros sistemas de escrita, como os arábico-índicos. Se você quer exatamente 0 a 9, escreva [0-9], que significa a mesma coisa em todas as linguagens.

Como validar no servidor, passo a passo

  1. Liste os campos e o tipo de cada um. Na loja: quantidade (inteiro de 1 a 999), CEP (8 dígitos, hífen opcional) e preço do produto no painel (até 7 dígitos, vírgula e até 2 casas).
  2. Normalize o mínimo. Converta para texto e tire espaços das pontas. Não "conserte" mais que isso: apagar letras em silêncio esconde erro de integração.
  3. Valide com regex ancorada. Uma por tipo, com tamanho.
  4. Converta só se passou, e confira a faixa depois de converter.
  5. Responda com erro claro (status 422 ou 400), dizendo o campo e o formato esperado, com a mesma frase que aparece na tela.
  6. Teste chamando a API direto, sem o formulário.

O código da loja, em Node.js com Express

const express = require('express');
const app = express();

function lerQuantidade(valor) {
  const s = String(valor ?? '').trim();
  if (!/^[0-9]{1,3}$/.test(s)) return null;
  const n = Number(s);
  return n >= 1 ? n : null;
}

function lerCep(valor) {
  const s = String(valor ?? '').trim();
  if (!/^[0-9]{5}-?[0-9]{3}$/.test(s)) return null;
  return s.replace('-', ''); // continua texto: '01310100'
}

function lerPrecoEmCentavos(valor) {
  const s = String(valor ?? '').trim();
  const m = /^([0-9]{1,7})(?:,([0-9]{1,2}))?$/.exec(s);
  if (!m) return null;
  return Number(m[1]) * 100 + Number((m[2] || '0').padEnd(2, '0'));
}

app.post('/api/carrinho/itens', express.json(), (req, res) => {
  const quantidade = lerQuantidade(req.body.quantidade);
  if (quantidade === null) {
    return res.status(422).json({
      campo: 'quantidade',
      erro: 'Informe um número inteiro de 1 a 999, sem ponto nem vírgula.'
    });
  }
  const cep = lerCep(req.body.cep);
  if (cep === null) {
    return res.status(422).json({
      campo: 'cep',
      erro: 'Informe o CEP com 8 dígitos, como 01310-100.'
    });
  }
  // grava quantidade (número) e cep (texto)
  res.status(201).json({ quantidade, cep });
});

app.listen(3000);

O String(valor) no começo trata o caso em que o JSON traz número em vez de texto: 12 vira "12" e passa; 12.5 vira "12.5" e é recusado; true vira "true" e é recusado. Em lerPrecoEmCentavos, "49,9" vira 4990 e "49.90" é recusado, porque o painel combinou vírgula. Se o seu sistema aceita os dois, decida e documente.

A prova com curl

curl -s -X POST http://localhost:3000/api/carrinho/itens \
  -H "Content-Type: application/json" \
  -d '{"quantidade": "12a", "cep": "01310-100"}'

{"campo":"quantidade","erro":"Informe um número inteiro de 1 a 999, sem ponto nem vírgula."}

Repita com "1e3", "", "-1", "0" e " 12 ". Os quatro primeiros têm de ser recusados; o último passa, porque o espaço nas pontas foi removido de propósito. Transforme essa lista em teste automatizado: o post sobre regex testada com exemplo mostra como montar a lista do que deve passar e do que deve falhar.

E o formulário?

A tela continua importante, porque evita a viagem até o servidor e ajuda a pessoa. Para código, use <input type="text" inputmode="numeric">: o celular mostra o teclado numérico e o valor continua texto, com zeros. O atributo pattern do HTML já é ancorado no texto inteiro, então pattern="[0-9]{5}-?[0-9]{3}" basta. Evite type="number" para código: ele aceita e, sinal e ponto, e alguns navegadores mudam o valor com a roda do mouse. Para quantidade, type="number" com min, max e step="1" funciona bem.

Ponha o formato ao lado do rótulo ("CEP, ex.: 01310-100") e use a mesma frase de erro do servidor. A regra repetida nos dois lados é o assunto de plugin de validação no navegador não dispensa o servidor.

Erros comuns

  • Regex sem âncoras. [0-9]+ aceita "12a", "abc1" e qualquer texto com um dígito no meio.
  • Converter antes de validar. parseInt transforma "12a" em 12 e Number transforma vazio em 0, sem erro nenhum.
  • Guardar código como número. O CEP 01310-100 perde o zero e a etiqueta sai errada; CPF começando com zero vira outro documento.
  • Uma regex genérica para tudo. "Só dígitos" aceita CPF com 3 dígitos e idade com 11. Cada campo tem tamanho e faixa.
  • Apagar caracteres inválidos em silêncio. O integrador manda "12 unidades", o servidor grava 12 e o erro de integração nunca aparece.
  • Confiar no $ em PHP ou Python. Uma quebra de linha no fim passa pela validação e chega ao banco.

Checklist

  • Cada campo "numérico" está classificado como quantidade, código ou decimal.
  • Toda regex de validação tem âncora no início e no fim (ou usa fullmatch/\z).
  • A conversão acontece só depois da validação, com faixa conferida.
  • CEP, CPF e afins são guardados como texto.
  • A API devolve erro com o nome do campo e o formato esperado.
  • Existe teste automatizado com "12a", "1e3", vazio, negativo e zero.
  • O formulário usa inputmode="numeric" para código e mostra um exemplo do formato.

Mande agora um "12a" direto para a API do seu formulário mais importante, sem passar pela tela: ela recusa com uma mensagem clara, ou grava 12 e segue em frente?

Avalie este artigo

Referência: https://stackoverflow.com/questions/19715303/regex-that-accepts-only-numbers-0-9-and-no-characters/19715367