Boas Práticas na Implementação de um Formulário de Contacto num Website
formulário · contacto · segurança · anti-spam · reCAPTCHA · Turnstile · PHPMailer · acessibilidade · LGPD | ✎ Duarte Spínola | 2026-07-11
Um formulário de contacto é o ponto de entrada mais explotável de um website. Sem protecção adequada, pode ser usado para enviar spam (através do servidor de email), injectar headers SMTP, executar XSS Stored, fazer brute-force de credenciais, ou violar o RGPD/LGPD por recolher dados sem consentimento. Este artigo cobre as boas práticas de implementação — desde a validação de input no frontend e backend, protecção anti-spam com reCAPTCHA ou Cloudflare Turnstile, envio seguro via SMTP com PHPMailer, até à acessibilidade WCAG 2.1 e conformidade legal com o RGPD.
⚠ **Atenção
** Um formulário sem validação server-side é uma porta aberta para ataques. A validação no frontend (JavaScript) é apenas para experiência de utilizador — pode ser facilmente bypassada. Toda a validação crítica tem de acontecer no servidor.
1. O que Está em Risco — Ameaças a um Formulário Desprotegido
| Ameaça | Como funciona | Consequência |
|---|---|---|
| Email Header Injection | Atacante injecta \r\n no campo “From” para enviar email para milhares de destinatários |
Servidor usado para spam → IP em blacklists → email legítimo bloqueado |
| XSS Stored | Atacante insere <script> num campo que é exibido noutra página |
Roubo de cookies de sessão de admins que visualizam as mensagens |
| SQL Injection | Se as mensagens são guardadas em BD sem prepared statements | Acesso à base de dados completa do website |
| Spam de bots | Bots preenchem o formulário automaticamente com links publicitários | Caixa de email inundada com spam, perda de tempo a filtrar |
| CSRF | Atacante enganar um utilizador autenticado a submeter um formulário não intencional | Acções executadas em nome do utilizador sem consentimento |
| Brute-force | Se o formulário tem login ou recuperação de password | Comprometimento de contas de utilizador |
| Violação de privacidade | Recolher dados pessoais sem consentimento ou política de privacidade | Coima RGPD até €20M ou 4% do volume de negócios |
2. Validação de Input — A Primeira Linha de Defesa
2.1 Princípio: never trust user input
Toda a informação recebida do formulário deve ser tratada como potencialmente maliciosa até provado o contrário. Isto aplica-se a todos os campos — nome, email, assunto, mensagem, campos ocultos, e até cookies e headers HTTP.
2.2 Validação no Frontend (experiência de utilizador)
A validação no frontend dá feedback imediato ao utilizador, mas não substitui a validação no servidor. Usar atributos HTML5 nativos:
<label for=”nome”>Nome *</label>
<input type=”text” id=”nome” name=”nome” required maxlength=”100″
pattern=”[A-Za-zÀ-ÿ\s]+”
oninvalid=”this.setCustomValidity(‘Use apenas letras’)”
oninput=”this.setCustomValidity(”)”>
<label for=”email”>Email *</label>
<input type=”email” id=”email” name=”email” required maxlength=”254″>
<label for=”assunto”>Assunto *</label>
<input type=”text” id=”assunto” name=”assunto” required maxlength=”200″>
<label for=”mensagem”>Mensagem *</label>
<textarea id=”mensagem” name=”mensagem” required maxlength=”5000″
minlength=”10″></textarea>
<button type=”submit”>Enviar</button>
</form>
Atributos-chave:
required— campo obrigatóriomaxlength— limite máximo de caracteres (previne inputs absurdamente longos)pattern— expressão regular para validar formatotype="email"— valida automaticamente formato de email
Referência: MDN — HTML form element.
2.3 Validação no Backend (segurança real)
A validação no servidor é obrigatória. Em PHP:
// Sanitizar e validar cada campo
$nome = filter_input(INPUT_POST, ‘nome’, FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$email = filter_input(INPUT_POST, ’email’, FILTER_SANITIZE_EMAIL);
$assunto = filter_input(INPUT_POST, ‘assunto’, FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$mensagem = filter_input(INPUT_POST, ‘mensagem’, FILTER_SANITIZE_FULL_SPECIAL_CHARS);
// Validar email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
die(‘Email inválido’);
}
// Validar comprimento (defesa contra inputs excessivamente longos)
if (strlen($nome) > 100 || strlen($assunto) > 200 || strlen($mensagem) > 5000) {
die(‘Campo excede o tamanho permitido’);
}
// Validar nome (apenas letras, espaços e acentos)
if (!preg_match(‘/^[\p{L}\s\-]+$/u’, $nome)) {
die(‘Nome contém caracteres inválidos’);
}
// Validar mensagem (mínimo 10 caracteres)
if (strlen(trim($mensagem)) < 10) {
die(‘Mensagem demasiado curta’);
}
?>
⚠ **Atenção
** Nunca usar FILTER_SANITIZE_FULL_SPECIAL_CHARS como única defesa contra XSS. Sanitizar na entrada e escapar na saída (output encoding). Ver secção 6.
2.4 Prevenir Email Header Injection
O ataque mais comum em formulários de email é a injecção de headers SMTP. Se o campo “email” do utilizador é colocado directamente no header From: sem validação, um atacante pode injectar quebras de linha (\r\n) para adicionar BCC, CC, ou mesmo um corpo de email completamente diferente.
Vulnerável:
mail($para, $assunto, $mensagem, “From: $email_do_utilizador”);
Se $email_do_utilizador contiver [email protected]\r\nBcc: [email protected], [email protected], ..., o servidor envia spam para milhares de destinatários.
Seguro:
// Validar email rigorosamente antes de usar em headers
$email = filter_var($email, FILTER_VALIDATE_EMAIL);
if ($email === false) {
die(‘Email inválido’);
}
// Verificar que não há quebras de linha
if (preg_match(‘/[\r\n]/’, $email) || preg_match(‘/[\r\n]/’, $assunto)) {
die(‘Input inválido’);
}
// Usar o email validado no header
$headers = “From: [email protected]\r\n”;
$headers .= “Reply-To: $email\r\n”;
?>
Referência: OWASP Input Validation Cheat Sheet.
3. Anti-Spam — Bloquear Bots sem Frustrar Utilizadores
3.1 Honeypot (anti-spam invisível)
Um campo honeypot é um campo oculto no formulário que utilizadores reais nunca preenchem, mas bots preenchem automaticamente. Se o campo tem valor, o formulário é rejeitado.
<div style=”position:absolute;left:-9999px;top:-9999px;” aria-hidden=”true”>
<label for=”website”>Não preencher este campo</label>
<input type=”text” id=”website” name=”website” tabindex=”-1″
autocomplete=”off” aria-hidden=”true”>
</div>
