Formulários Web Seguros: Validação, CSRF e CAPTCHA na Prática

Todo o formulário numa página web é uma porta aberta para o servidor: o contact form do site da empresa, o login do portal interno, o registo num sistema de tickets. Sem as protecções certas, essa porta é usada por bots que publicam spam, por atacantes que injectam código SQL e por utilizadores que “não queria mesmo apagar” o registo. Este artigo reúne as boas práticas que fazem um formulário robusto: validação do lado do servidor (o cliente nunca conta), token CSRF para impedir envios forjados, honeypot e CAPTCHA contra bots, e os erros clássicos que colocam dados do utilizador em risco.

Tópico Resumo
Validação no cliente UX imediata, mas jamais como barreira de segurança
Validação no servidor A única que conta: regras, tipos e limites
Protecção CSRF Token secreto por sessão em cada formulário
Honeypot Campo invisível que só um robot preenche
CAPTCHA reCAPTCHA v3 e hCaptcha quando o risco justifica
Erros comuns SQL injection, XSS, upload mal validado

Neste artigo

Validação no cliente vs validação no servidor

São duas coisas diferentes com dois objectivos diferentes. A validação no navegador (HTML5: required, type=”email”, maxlength) serve a pessoa: recebe o erro imediatamente, sem esperar pelo servidor. A validação no servidor serve a aplicação: repete todas as regras, mesmo as do cliente, porque qualquer alguém pode enviar um POST com curl sem passar pelo formulário. O navegador é sugestão, o servidor é lei.

Nunca confies no cliente

Desligar o JavaScript do navegador desliga TODAS as validações do lado do cliente — e é de longe a coisa mais simples que um atacante faz. Toda a regra aplicada só no navegador é uma sugestão, não um controlo. A validação do servidor é obrigatória e redundante quanto baste.

Validação no servidor: as regras essenciais

  • Tipo certo para cada campo: inteiro (id da categoria), email (filter_var), data (DateTime::createFromFormat). Nunca aceites strings “à espera de validação posterior”.
  • Allowlist, não blocklist: validas o que é aceitável (ex.: país ∈ lista), não tentas filtrar “os maus” — o que existe no universo de input ruim é infinito.
  • Tamanho máximo por campo: mb_strlen($nome) <= 100, e limitar o POST total (post_max_size em PHP) para evitar DoS por payload gigante.
  • Normalizar: trim em tudo, converter emails para minúsculas, remover espaços duplicados antes de validar.
  • Mensagens de erro seguras: “Email inválido” ao utilizador; o detalhe técnico (stack trace, a query SQL) fica só no log do servidor.
<?php
// Validação completa de um contacto (PHP 8):
$errores = [];

$nome  = trim($_POST['nome']  ?? '');
$email = trim($_POST['email'] ?? '');
$mensagem = trim($_POST['mensagem'] ?? '');

if ($nome === '' || mb_strlen($nome) > 100) {
    $erros['nome'] = 'Nome obrigatório (máx. 100 caracteres)';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $erros['email'] = 'Email inválido';
}
if (mb_strlen($mensagem) < 10 || mb_strlen($mensagem) > 2000) {
    $erros['mensagem'] = 'Mensagem entre 10 e 2000 caracteres';
}

if ($erros) {
    http_response_code(422);
    echo json_encode(['ok' => false, 'erros' => $erros]);
    exit;
}
// seguir: CSRF verificado ANTES disto; ver secção abaixo

Token CSRF: o formulário só aceita os teus envios

Um atacante externo pode criar uma página que faz POST ao teu formulário a partir do navegador de um utilizador autenticado (Cross-Site Request Forgery). O antídoto é um token CSRF: um valor aleatório gerado na sessão do utilizador, embutido no formulário e verificado no servidor. Como o site do atacante não consegue ler o token (same-origin policy), o seu envio é rejeitado.

<?php
session_start();
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// No formulário:
// <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">

// No POST:
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
    http_response_code(403);
    exit('Pedido inválido');
}

CSRF em todas as acções que mudam estado

O token aplica-se a todos os POST/PUT/DELETE que alteram dados — criar utilizadores, alterar emails, submeter formulário de contacto. Em aplicações SPA, é comum usar o header X-Requested-With + token em header; o princípio é o mesmo.

Honeypot: armadilha invisível para bots

O honeypot é o mecanismo anti-bot mais barato que existe: um campo hidden (visível só para robots que leem o HTML) chamado algo tipo “website” ou “telefão” que o humano não preenche — porque não o vê. Se o campo chega PREENCHIDO ao servidor, é um robot: rejeitas em silêncio (responde 200 para não o “educar”). Filtra 80-90% do spam sem incomodar ninguém.

<!-- No formulário (invisível via CSS, não via display:none no HTML): -->
<div style="position:absolute;left:-9999px;" aria-hidden="true">
  <label>Website <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>

// No servidor:
if (!empty($_POST['website'])) {
    // bot confirmado — responder como sucesso para não alertar o bot
    http_response_code(200);
    exit;
}

CAPTCHA: quando e como usar

O CAPTCHA é recurso pesado e degradador de experiência — usar quando o honeypot não chega (fluxos de registo, votações, formulários acessíveis ao público). O reCAPTCHA v3 e o hCaptcha funcionam de forma invisível: atribuem uma pontuação de 0 a 1 a cada visitante, e é com base nessa pontuação que decidimos o que acontece a partir de 0,5: abaixo desse valor, pedido bloqueado; acima, permitido sem atrito.

Solução Privacidade / UX Boa escolha para
reCAPTCHA v3 (score) Invisível ao utilizador, Google recolhe dados Contact forms com muito spam
hCaptcha Alternativa com menos dados para a Google Preocupação com privacidade
Cloudflare Turnstile Sem interação, não vende dados (grátis) PME com Cloudflare à frente do site
Proof of Work (altcha) Sem cookies nem terceiros Registos públicos, máxima privacidade
# Server-side (reCAPTCHA v3) — verificar SEMPRE do lado do servidor:
$tok = $_POST['recaptcha_response'] ?? '';
$check = file_get_contents('https://www.google.com/recaptcha/api/siteverify?' . http_build_query([
    'secret'   => $SECRET_KEY,
    'response' => $tok,
    'remoteip' => $_SERVER['REMOTE_ADDR'],
]));
$res = json_decode($check, true);
if (!$res['success'] || $res['score'] < 0.5) {
    http_response_code(403);
    exit('Verificação anti-bot falhou');
}

O CAPTCHA não substitui a validação

Mesmo com reCAPTCHA, continua a validar tudo no servidor: o CAPTCHA distingue humanos de bots, não garante que o humano envia dados válidos e bem-intencionados.

Os erros clássicos (e como evitar)

Erro Consequência Correcção
Construir SQL com strings concatenadas SQL injection: leitura ou destruição da base de dados Queries preparadas com parâmetros (PDO, prepared statements)
Confiança no hidden field do preço Cliente compra por 1€ o que custa 100€ O servidor recalcula todos os valores (nunca o cliente)
Guardar a password sem hash Fuga total de credenciais num dump password_hash() com bcrypt/argon2 e password_verify()
Revelar na resposta de erro se o email existe Mapeamento de contas (account enumeration) “Se o email existir, enviámos instruções” — sempre igual
Upload: validar apenas a extensão Script PHP disfarçado de .jpg Verificar MIME real + guardar fora do webroot + nomes aleatórios
# Sempre: prepared statements (nunca concatenar input no SQL):
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

# E a password nunca se guarda "secreta" — sempre hash:
$hash = password_hash($senha, PASSWORD_DEFAULT);
if (password_verify($senha, $user['senha_hash'])) { /* login ok */ }

Rate limiting: travar o martelo

Mesmo com CAPTCHA, um atacante paciente manda 500 requests/segundos. O rate limiting no servidor (ou no Cloudflare/Nginx à frente) conta pedidos por IP e devolve 429 depois do limite. Para forms críticos (login, registo, reset de password), limites por IP e por conta: 5 tentativas por 15 minutos é o padrão prudente.

# Nginx (limit_req): 5 pedidos/minuto por IP com burst de 3
limit_req_zone $binary_remote_addr zone=form:10m rate=5r/m;
location = /api/contacto {
    limit_req zone=form burst=3 nodelay;
    limit_req_status 429;
}

9. Ferramentas Gratuitas para Validar o Formulário

Depois de implementar as protecções, valida o resultado. Todas as ferramentas abaixo são gratuitas e cobrem o checklist — aponta-as sempre a formulários que te pertencem.

Ferramenta O que valida Como usar
OWASP ZAP Scans automáticos: XSS, SQL injection e formulários sem token CSRF Instalar, “Quick Scan” ao URL do formulário; Active Scan para testes activos
Burp Suite Community Teste manual: reenviar POST sem CSRF, com campos extra, com valores alterados Proxy no navegador, intercer o submit e alterar o pedido
Mail-Tester Qualidade do email enviado: SPF, DKIM, spam score (nota /10) Enviar o formulário para o endereço gerado e ver o relatório
WAVE + Lighthouse Acessibilidade: labels, ARIA, foco visível, contraste WAVE no browser; Lighthouse na aba Accessibility do DevTools
Security Headers CSP, HSTS, X-Frame-Options, Referrer-Policy com correções exactas Inserir a URL do site e corrigir os headers apontados
SSL Labs Certificado, cifras e HSTS do domínio Scan ao domínio; meta: nota A ou A+
Pentest-Tools.com Scan superficial de XSS/SQLi nos parâmetros (free tier) Submeter o URL do formulário no free scan

Começa pelo ZAP (automático) para apanhar o óbvio, usa o Mail-Tester para validar o envio real de email e fecha com WAVE e Security Headers. Juntos, cobrem cerca de 80% do checklist deste artigo sem qualquer custo — o resto é auditoria manual com o Burp nos casos em que a lógica de negócio importa.

10. Checklist de um formulário à prova de balas

  • ✅ Validação HTML5 para a experiência — e a MESMA validação no servidor.
  • ✅ CSRF token em todas as operações que mudam estado.
  • ✅ Honeypot + rate limit nos formulários públicos.
  • ✅ CAPTCHA (reCAPTCHA v3/Turnstile) só onde o risco justifica.
  • ✅ Prepared statements em TODAS as queries (zero concatenação).
  • ✅ Erros genéricos ao utilizador; detalhes técnicos só no log.
  • ✅ Uploads: MIME real, tamanho, fora do webroot, nomes aleatórios.
  • ✅ Nunca recalcula o que vem do cliente (preços, descontos, perfis).
  • Um formulário seguro não é o que tem mais campos bloqueados — é o que valida no servidor, protege o envio com CSRF, sabe distinguir humano de bot sem torturar o utilizador, e falha sempre do lado seguro. O código de exemplo usa PHP por ser o mais comum no alojamento web das PMEs, mas os princípios aplicam-se a qualquer stack (ASP.NET, Node.js, Python).

    Artigos relacionados: