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:

<form method=”POST” action=”/contacto-processar.php”>
<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ório
  • maxlength — limite máximo de caracteres (previne inputs absurdamente longos)
  • pattern — expressão regular para validar formato
  • type="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:

<?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:

// NUNCA fazer isto
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:

<?php
// 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.

<!– Campo honeypot — oculto via CSS, não via hidden (bots ignoram hidden) –>
<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>

No backend:

<?php
if (!empty($_POST[‘website’])) {
// Bot preencheu o honeypot — rejeitar silenciosamente
// Responder 200 OK para o bot não tentar outra abordagem
http_response_code(200);
exit;
}
?>

3.2 Cloudflare Turnstile (alternativa gratuita ao reCAPTCHA)

O Cloudflare Turnstile é a alternativa moderna ao Google reCAPTCHA. É gratuito, não mostra challenge visual ao utilizador (na maioria dos casos), e não requer a interação “seleccionar semáforos” que o reCAPTCHA v2 impõe.

Integração no frontend:

<!– 1. Carregar o script –>
<script src=”https://challenges.cloudflare.com/turnstile/v0/api.js” async defer></script>

<!– 2. Adicionar o widget ao formulário –>
<form method=”POST” action=”/contacto-processar.php”>
<!– campos do formulário –>
<div class=”cf-turnstile” data-sitekey=”SITE_KEY_AQUI”></div>
<button type=”submit”>Enviar</button>
</form>

Validação no backend:

<?php
$turnstile_response = $_POST[‘cf-turnstile-response’] ?? ”;
$secret_key = ‘SECRET_KEY_AQUI’;

$ch = curl_init(‘https://challenges.cloudflare.com/turnstile/v0/siteverify’);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
‘secret’ => $secret_key,
‘response’ => $turnstile_response,
‘remoteip’ => $_SERVER[‘REMOTE_ADDR’],
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);

if (!$result[‘success’]) {
die(‘Verificação anti-spam falhou. Tente novamente.’);
}
?>

Referência: Cloudflare Turnstile.

3.3 Google reCAPTCHA v3 (alternativa)

O reCAPTCHA v3 funciona em background sem interacção do utilizador — atribui uma pontuação (0.0 a 1.0) baseada no comportamento. Usar se já se tem conta Google Ads/Search Console.

Integração no frontend:

<script src=”https://www.google.com/recaptcha/api.js?render=SITE_KEY”></script>
<script>
grecaptcha.ready(function() {
grecaptcha.execute(‘SITE_KEY’, {action: ‘contacto’}).then(function(token) {
document.getElementById(‘recaptcha-token’).value = token;
});
});
</script>

<form method=”POST” action=”/contacto-processar.php”>
<input type=”hidden” id=”recaptcha-token” name=”recaptcha-token”>
<button type=”submit”>Enviar</button>
</form>

Validação no backend:

<?php
$token = $_POST[‘recaptcha-token’] ?? ”;
$ch = curl_init(‘https://www.google.com/recaptcha/api/siteverify’);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
‘secret’ => ‘SECRET_KEY’,
‘response’ => $token,
‘remoteip’ => $_SERVER[‘REMOTE_ADDR’],
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = json_decode(curl_exec($ch), true);

// reCAPTCHA v3 usa score — definir threshold
if (!$result[‘success’] || $result[‘score’] < 0.5) {
die(‘Verificação anti-spam falhou.’);
}
?>

Referência: Google reCAPTCHA.

3.4 Rate Limiting (limitar tentativas)

Para evitar que um bot submeta o formulário centenas de vezes por minuto:

<?php
session_start();
$ip = $_SERVER[‘REMOTE_ADDR’];
$key = “form_submissions_{$ip}”;

if (!isset($_SESSION[$key])) {
$_SESSION[$key] = [‘count’ => 0, ‘first’ => time()];
}

// Janela de 10 minutos = 600 segundos
$window = 600;
$max_submissions = 5;

if (time() – $_SESSION[$key][‘first’] > $window) {
// Reset janela
$_SESSION[$key] = [‘count’ => 0, ‘first’ => time()];
}

$_SESSION[$key][‘count’]++;

if ($_SESSION[$key][‘count’] > $max_submissions) {
http_response_code(429);
die(‘Limite de submissões atingido. Tente novamente mais tarde.’);
}
?>

4. Envio Seguro de Email — Usar SMTP com PHPMailer

4.1 Nunca usar a função mail() nativa do PHP

A função mail() do PHP envia directamente para o MTA local (sendmail, postfix) sem autenticação SMTP. Isto causa dois problemas:

  1. Sem autenticação — o email é enviado sem login SMTP, o que significa que muitos provedores de email do destinatário (Gmail, Outlook) podem marcar como spam
  2. Sem controlo de headers — mais susceptível a header injection

Referência: PHP mail() — documentação oficial.

4.2 Usar PHPMailer com SMTP autenticado

O PHPMailer é a biblioteca padrão de facto para enviar email em PHP. Suporta SMTP autenticado, TLS, attachments, e HTML. Instalar via Composer:

composer require phpmailer/phpmailer

Envio seguro:

<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

require ‘vendor/autoload.php’;

$mail = new PHPMailer(true);

try {
// Configuração SMTP
$mail->isSMTP();
$mail->Host = ‘smtp.dominio.pt’; // Servidor SMTP
$mail->SMTPAuth = true;
$mail->Username = ‘[email protected]’; // Conta SMTP
$mail->Password = ‘password_segura’; // Password SMTP
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // TLS
$mail->Port = 587; // Porta TLS

// Remetente (fixo — nunca usar email do utilizador como From)
$mail->setFrom(‘[email protected]’, ‘Website Dominio’);
$mail->addReplyTo($email, $nome); // Reply-To = email do utilizador
$mail->addAddress(‘[email protected]’); // Destinatário

// Conteúdo
$mail->isHTML(false); // Enviar como texto plano (mais seguro)
$mail->Subject = “Formulário contacto: $assunto”;
$mail->Body = “Nome: $nome\nEmail: $email\n\nMensagem:\n$mensagem”;

$mail->send();
echo ‘Mensagem enviada com sucesso’;
} catch (Exception $e) {
// NUNCA mostrar $mail->ErrorInfo ao utilizador (expõe info do servidor)
error_log(‘PHPMailer erro: ‘ . $mail->ErrorInfo);
echo ‘Erro ao enviar mensagem. Tente novamente.’;
}
?>

Regras críticas:

  • From fixo — o remetente (setFrom) é sempre o email do website, nunca o email do utilizador. O email do utilizador vai em Reply-To
  • TLS obrigatório — usar ENCRYPTION_STARTTLS na porta 587 ou ENCRYPTION_SMTPS na porta 465
  • Erros em log — nunca mostrar $mail->ErrorInfo ao utilizador (expõe caminhos de ficheiros, versões, configuração do servidor)

Referência: PHPMailer — Packagist.

5.1 Consentimento explícito

O formulário deve ter um checkbox de consentimento desmarcado por defeito (não pré-seleccionado):

<label for=”consentimento”>
<input type=”checkbox” id=”consentimento” name=”consentimento” value=”sim” required>
Aceito que os meus dados sejam utilizados para resposta a este contacto,
conforme a <a href=”/politica-privacidade/”>Política de Privacidade</a>.
</label>

5.2 Elementos obrigatórios no formulário

Elemento Requisito Implementação
Checkbox de consentimento Obrigatório, desmarcado por defeito <input type="checkbox" required>
Link para política de privacidade Obrigatório Link visível junto ao checkbox
Campos mínimos Não pedir mais dados do que o necessário Nome, email, mensagem — não pedir NIF, morada, telefone se não são necessários
Aviso de dados Indicar que os dados são processados Texto explicativo junto ao formulário
Direito de acesso Permitir ao utilizador pedir os seus dados Link ou email de contacto para solicitações RGPD

5.3 No backend

<?php
// Verificar consentimento
$consentimento = filter_input(INPUT_POST, ‘consentimento’, FILTER_SANITIZE_STRING);
if ($consentimento !== ‘sim’) {
die(‘Consentimento obrigatório para processar o formulário.’);
}

// Registar consentimento com timestamp e IP (para auditoria)
$consentimento_log = date(‘Y-m-d H:i:s’) . ” | IP: {$_SERVER[‘REMOTE_ADDR’]} | Consentimento: sim\n”;
file_put_contents(‘/var/log/consentimentos.log’, $consentimento_log, FILE_APPEND | LOCK_EX);
?>

6. Acessibilidade — WCAG 2.1

Um formulário acessível é mais fácil de usar para todos — incluindo utilizadores de leitores de ecrã, navegação por teclado, e pessoas com baixa visão.

6.1 Labels associadas a inputs

<!– Correcto — label associado via for/id –>
<label for=”nome”>Nome *</label>
<input type=”text” id=”nome” name=”nome” required>

<!– Incorrecto — sem label, screen readers não identificam o campo –>
<input type=”text” name=”nome” placeholder=”Nome”>

6.2 Atributos ARIA

<label for=”email”>Email *</label>
<input type=”email” id=”email” name=”email” required
aria-required=”true”
aria-describedby=”email-erro”>

<div id=”email-erro” role=”alert” style=”display:none;”>
Email inválido. Verifique o formato.
</div>