Pular para o conteúdo
PHP

Honeypot em PHP contra bots em formulários

Um pote de mel dourado com pequenos robôs presos dentro, sobre um pergaminho de formulário, e um unicórnio guardando o formulário verdadeiro com uma varinha

Olá meus Unicórnios! 🦄✨

Você monta um formulário de contato bonitinho, coloca no ar, vai dormir feliz. No dia seguinte tem trinta mensagens na caixa oferecendo backlink, criptomoeda e um serviço de SEO que ninguém pediu. 😅 Pois é.

A primeira reação de todo mundo é a mesma: vou botar um CAPTCHA. Só que o CAPTCHA cobra o preço no lugar errado. Quem paga é a pessoa de verdade, que tem de achar o semáforo na foto borrada, enquanto o bot moderno resolve aquilo melhor que você. E ainda entra uma dependência externa no site, com script de terceiro e tudo.

Existe um jeito mais velho, mais simples e, para o bot genérico que varre a web, mais eficaz: o honeypot, o pote de mel. Você deixa um campo na página que só um robô preencheria. Quem preenche, se entrega.

🍯 A ideia em uma frase

O bot que enche formulário pela internet afora não lê a sua página: ele lê o HTML. Encontra os <input>, preenche todos, e manda. É burro de propósito, porque precisa funcionar em milhões de formulários diferentes que ele nunca viu.

Então a armadilha é essa: você põe no HTML um campo que existe para o robô e não existe para a pessoa. A pessoa não vê, porque o CSS tirou da tela. O robô vê, porque para ele o CSS não existe. Chegou preenchido? Não era gente.

🚫 A armadilha que não funciona: type="hidden"

Esse é o erro que quase todo tutorial de honeypot ainda ensina, e é onde a maioria das implementações morre calada. Parece a escolha óbvia: quero um campo escondido, o HTML tem um campo escondido, pronto.

<!-- NÃO faça isto: o bot ignora e a armadilha nunca dispara -->
<input type="hidden" name="site" value="">

O problema é que o bot também conhece o type="hidden", e ele aprendeu a não tocar. É ali que moram token de CSRF, ID de registro, campo de controle: mexer naquilo é o jeito mais rápido de o envio ser recusado por motivo nenhum. Então o bot bem-comportado deixa quieto, manda o formulário, e a sua armadilha fica lá, vazia, achando que não passou bot nenhum.

A isca precisa parecer um campo comum no HTML e sumir só na tela. Quem esconde é o CSS, sempre.

🎨 O CSS que esconde do jeito certo

Aqui tem outra armadilha, e essa machuca gente de verdade. O reflexo é escrever display: none, e você não pode.

Dois motivos. O primeiro é que o display:none é fácil demais de detectar: o bot que se dá ao trabalho de olhar o CSS resolve isso em uma linha, e pula o campo. O segundo é mais sério: campo com display:none some da árvore de acessibilidade de um jeito e o campo fora da tela some de outro, e essa diferença importa quando você monta o resto da proteção.

O padrão que funciona é jogar o campo para fora da tela:

.isca {
    position: absolute;
    left: -9999px;
    top: auto;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

O campo continua existindo, continua sendo enviado com o formulário se alguém o preencher, e está a dez mil pixels à esquerda da primeira coluna da página. Nenhuma pessoa vidente vai encontrar aquilo por acaso.

♿ A parte que quase todo mundo esquece: quem usa leitor de tela

Se você só fizer o CSS acima e parar por aí, você acabou de criar uma armadilha para pessoas cegas. 😳

Pense em quem navega por áudio: o leitor de tela lê o documento, não a tela. Um campo fora da tela continua sendo um campo do formulário para ele, com rótulo e tudo. A pessoa ouve "Site, caixa de edição", digita o site dela porque parece um campo legítimo, e é descartada como bot. Silenciosamente, porque a resposta vai dizer que deu tudo certo.

Pense também em quem navega por teclado: aperta Tab depois do campo de mensagem e o foco cai num lugar invisível da página. A pessoa vê o foco sumir e não entende o que aconteceu.

São três defesas, e as três precisam estar juntas:

  • aria-hidden="true" no contêiner, que tira o campo da árvore de acessibilidade. O leitor de tela simplesmente não anuncia que ele existe.
  • tabindex="-1" no <input>, que tira o campo da ordem do Tab. O foco nunca cai lá.
  • Um <label> honesto dizendo "deixe este campo em branco", que é a rede de segurança: se alguma tecnologia assistiva furar as duas defesas acima, a pessoa ouve a instrução e sabe o que fazer.

E tem um quarto detalhe, pequeno e importante: autocomplete="off". Sem ele, o navegador da própria pessoa pode preencher a isca sozinho, porque o campo se chama algo como site e o autopreenchimento acha que está ajudando. Aí você descarta um visitante legítimo por culpa do navegador dele.

<!-- A armadilha. Sai da tela pelo CSS, nunca com type="hidden": o bot
     ignora campo escondido e preenche tudo que parece um campo normal.
     O aria-hidden tira do leitor de tela e o tabindex="-1" tira do Tab,
     então quem navega por teclado ou por áudio nunca cai nela. -->
<div class="isca" aria-hidden="true">
    <label for="site">Deixe este campo em branco</label>
    <input type="text" id="site" name="site" value=""
           autocomplete="off" tabindex="-1">
</div>

O nome do campo merece um segundo de atenção. Não chame de honeypot nem de isca, porque aí você está avisando o bot. Escolha um nome que soe apetitoso e comum: site, url, empresa, telefone2. Quanto mais parecer um campo que vale a pena preencher, melhor.

⏱️ A segunda armadilha: o cronômetro

A isca sozinha pega muita coisa, mas não pega tudo. Um bot escrito especificamente para o seu formulário aprende a deixar aquele campo quieto.

Aí entra a segunda medida, que é quase de graça: quanto tempo passou entre abrir a página e enviar o formulário?

Uma pessoa lê o rótulo, pensa no que escrever, digita, revisa. Isso são dezenas de segundos, no mínimo. Um bot faz o GET e o POST em sequência, na mesma respiração. Nos testes deste artigo o registro saiu literalmente como respondeu em 0s.

Três segundos já separa os dois mundos com folga.

E aqui mora a armadilha da armadilha: onde você guarda esse instante de abertura? O reflexo é pôr num campo escondido do formulário, junto com o resto. Só que campo escondido é dado que o bot controla: ele reescreve o valor para o que quiser e o seu cronômetro vira enfeite.

O instante tem de morar no servidor, na sessão:

// Guarda o instante em que a página foi aberta. Fica na sessão, no servidor:
// se ficasse num campo escondido, o bot poderia reescrever o valor.
if (empty($_SESSION['aberto_em'])) {
    $_SESSION['aberto_em'] = time();
}

O navegador carrega só o cookie da sessão, que não diz nada sobre horário. Não tem o que falsificar.

🤫 O que fazer quando pegar um bot (a parte contraintuitiva)

Se você só for ler um pedaço deste artigo, leia este. 🙏

Pegou o bot. O reflexo agora é responder o que a lógica manda: "Erro: envio recusado". E é exatamente aí que a maioria das armadilhas se estraga sozinha, em algumas semanas, sem ninguém entender por quê.

Mensagem de erro é aula grátis. Quem mantém um bot de spam olha as respostas: se o seu site começou a recusar, ele tira um campo, tenta de novo, tira outro, tenta de novo, até passar. Você acabou de dar a ele um jeito de descobrir a sua armadilha por tentativa e erro, com resposta imediata a cada tentativa.

Faça o contrário. Responda a mesma mensagem de sucesso de sempre, palavra por palavra, e jogue a mensagem fora em silêncio:

if ($ehBot) {
    // Responde SUCESSO, e não erro. Mensagem de erro ensina o bot a acertar
    // na próxima tentativa; o silêncio deixa ele achando que funcionou.
    $mensagemDeSucesso = 'Mensagem enviada, obrigada!';
}

O bot marca o seu formulário como "funcionou", vai embora e nunca mais volta para conferir. É o melhor desfecho possível: ele não insiste, não aprende e não te procura de novo.

📄 O arquivo inteiro

Um arquivo só, PHP puro, sem dependência nenhuma, que roda com o servidor embutido do PHP. É para ler de cima para baixo:

<?php
// Formulário de contato com armadilha (honeypot) para bots.
// Roda sozinho: php -S 127.0.0.1:8411 contato.php

session_start();

$TEMPO_MINIMO = 3; // segundos que um humano leva, no mínimo, para preencher

// Guarda o instante em que a página foi aberta. Fica na sessão, no servidor:
// se ficasse num campo escondido, o bot poderia reescrever o valor.
if (empty($_SESSION['aberto_em'])) {
    $_SESSION['aberto_em'] = time();
}

$mensagemDeSucesso = '';

function registrarSuspeita($motivo)
{
    $linha = date('Y-m-d H:i:s') . ' | bot descartado | ' . $motivo . "\n";
    file_put_contents(__DIR__ . '/suspeitas.log', $linha, FILE_APPEND);
}

function guardarMensagem($nome, $email, $texto)
{
    $linha = date('Y-m-d H:i:s') . ' | ' . $nome . ' | ' . $email . ' | ' . $texto . "\n";
    file_put_contents(__DIR__ . '/mensagens.log', $linha, FILE_APPEND);
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nome     = isset($_POST['nome'])     ? trim($_POST['nome'])     : '';
    $email    = isset($_POST['email'])    ? trim($_POST['email'])    : '';
    $texto    = isset($_POST['mensagem']) ? trim($_POST['mensagem']) : '';
    $isca     = isset($_POST['site'])     ? trim($_POST['site'])     : '';

    $segundos = time() - (int) $_SESSION['aberto_em'];

    $ehBot = false;

    // Armadilha 1: o campo-isca tinha de chegar vazio. Humano nenhum o vê.
    if ($isca !== '') {
        $ehBot = true;
        registrarSuspeita('preencheu o campo-isca com "' . $isca . '"');
    }

    // Armadilha 2: rápido demais. O bot responde no mesmo segundo.
    if (!$ehBot && $segundos < $TEMPO_MINIMO) {
        $ehBot = true;
        registrarSuspeita('respondeu em ' . $segundos . 's (mínimo ' . $TEMPO_MINIMO . 's)');
    }

    if ($ehBot) {
        // Responde SUCESSO, e não erro. Mensagem de erro ensina o bot a acertar
        // na próxima tentativa; o silêncio deixa ele achando que funcionou.
        $mensagemDeSucesso = 'Mensagem enviada, obrigada!';
    } else if ($nome === '' || $email === '' || $texto === '') {
        $mensagemDeSucesso = 'Erro: preencha o nome, o e-mail e a mensagem.';
    } else if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $mensagemDeSucesso = 'Erro: esse e-mail não parece válido.';
    } else {
        guardarMensagem($nome, $email, $texto);
        $mensagemDeSucesso = 'Mensagem enviada, obrigada!';
        $_SESSION['aberto_em'] = time(); // zera o cronômetro para o próximo envio
    }
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Fale comigo</title>
<style>
.isca {
    position: absolute;
    left: -9999px;
    top: auto;
    width: 1px;
    height: 1px;
    overflow: hidden;
}
</style>
</head>
<body>
<?php if ($mensagemDeSucesso !== '') { ?>
    <p><?php echo htmlspecialchars($mensagemDeSucesso, ENT_QUOTES, 'UTF-8'); ?></p>
<?php } ?>
<form method="post" action="">
    <p><label for="nome">Nome</label><br>
       <input type="text" id="nome" name="nome"></p>

    <p><label for="email">E-mail</label><br>
       <input type="email" id="email" name="email"></p>

    <p><label for="mensagem">Mensagem</label><br>
       <textarea id="mensagem" name="mensagem"></textarea></p>

    <!-- A armadilha. Sai da tela pelo CSS, nunca com type="hidden": o bot
         ignora campo escondido e preenche tudo que parece um campo normal.
         O aria-hidden tira do leitor de tela e o tabindex="-1" tira do Tab,
         então quem navega por teclado ou por áudio nunca cai nela. -->
    <div class="isca" aria-hidden="true">
        <label for="site">Deixe este campo em branco</label>
        <input type="text" id="site" name="site" value=""
               autocomplete="off" tabindex="-1">
    </div>

    <p><button type="submit">Enviar</button></p>
</form>
</body>
</html>

Repare em duas linhas fáceis de passar batido. A primeira é o !$ehBot && na armadilha do tempo: sem ele, um bot que caísse nas duas armadilhas geraria duas linhas no log para o mesmo envio, e a sua contagem de bots ficaria inflada.

A segunda é o $_SESSION['aberto_em'] = time() no final do caminho feliz. Sem ele, a pessoa que envia uma mensagem e logo em seguida quer enviar outra é barrada pelo cronômetro, porque a sessão ainda guarda a abertura da primeira página. Zerar ali é o que evita punir justamente quem mais interage com você.

🧪 Colocando para rodar

Suba o arquivo com o servidor embutido do PHP, numa porta qualquer que esteja livre:

php -S 127.0.0.1:8411 contato.php

Agora o bot. Um bot de formulário é, no fundo, um curl com pressa: ele busca a página e dispara o POST em seguida, preenchendo tudo que encontrou, inclusive a isca. O -c e o -b guardam e reenviam o cookie da sessão, que é o que faz o cronômetro do servidor valer para as duas requisições:

curl -s -c cookie-bot.txt -o /dev/null http://127.0.0.1:8411/

curl -s -b cookie-bot.txt -X POST http://127.0.0.1:8411/ \
  -d "nome=Comprador Barato" -d "[email protected]" \
  -d "mensagem=Visite meu site" -d "site=https://exemplo-spam.test"

E a pessoa, que é o mesmo curl com duas diferenças: espera cinco segundos e deixa a isca vazia.

curl -s -c cookie-humana.txt -o /dev/null http://127.0.0.1:8411/
sleep 5
curl -s -b cookie-humana.txt -X POST http://127.0.0.1:8411/ \
  -d "nome=Paloma" -d "[email protected]" \
  -d "mensagem=Oi, gostei do artigo" -d "site="

Os dois recebem a mesma resposta na tela. Essa é a graça da coisa: por fora, não dá para distinguir. A diferença só aparece nos dois arquivos de log.

Terminal mostrando o envio do bot recebendo a mensagem de sucesso, o arquivo mensagens.log com apenas a mensagem da pessoa, e o suspeitas.log com três bots descartados

Três envios descartados, um guardado. O mensagens.log tem só a linha da Paloma. O suspeitas.log mostra os dois jeitos de cair: dois bots pelo campo-isca e um pelo cronômetro, com o respondeu em 0s que é a assinatura de quem não é gente. 🎉

E note o que não aparece em lugar nenhum: uma mensagem de erro. Os três bots foram embora achando que a mensagem tinha sido entregue.

⚠️ Onde essa proteção não chega

Honeypot é excelente contra o bot genérico, aquele que varre a web preenchendo tudo que encontra. É de graça, não incomoda ninguém e não traz dependência nenhuma para dentro do seu site.

O que ele não é: proteção contra um ataque dirigido. Se alguém escrever um script olhando para o seu formulário especificamente, essa pessoa abre o HTML, vê a div.isca, deixa vazia, espera cinco segundos e passa. Contra isso, a isca é a primeira camada, não a única, e aí entram limite de envios por endereço e moderação antes de publicar.

A boa notícia é a proporção: a esmagadora maioria do lixo que chega num formulário de contato pequeno vem do bot genérico. Duas dúzias de linhas de PHP resolvem o grosso, e quem visita o seu site nunca fica sabendo que a armadilha existe. 🍯

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Por que não usar <code>type="hidden"</code> no campo-isca?
Porque o bot ignora campo oculto de propósito: ele sabe que ali costuma morar token e ID, não dado de formulário. A isca só funciona se parecer um campo normal no HTML e sumir apenas na tela, pelo CSS.
O leitor de tela não vai cair na armadilha?
Não, se você fizer as três coisas juntas: aria-hidden="true" no contêiner tira o campo da árvore de acessibilidade, tabindex="-1" tira do caminho do Tab, e o CSS usa posicionamento fora da tela, nunca display:none. Um <label> dizendo "deixe este campo em branco" é a rede de segurança.
Por que responder sucesso quando pego um bot?
Porque mensagem de erro é aula grátis. Se você responder "campo inválido", quem mantém o bot vê a recusa, descobre qual campo entregou e ajusta. Respondendo o mesmo "Mensagem enviada, obrigada!" de sempre, o bot vai embora achando que funcionou e nunca volta corrigido.
Qual tempo mínimo de preenchimento devo usar?
Três segundos é um piso seguro. Bot costuma responder no mesmo segundo (o teste do artigo registrou respondeu em 0s), e nem o humano mais rápido digita nome, e-mail e mensagem em menos que isso. Se o formulário for de um campo só, baixe para 2.
Por que o instante de abertura fica na sessão e não num campo escondido?
Porque campo escondido é dado que o bot controla: ele reescreve o valor e o cronômetro vira decoração. Na $_SESSION o instante mora no servidor, e o navegador só carrega o cookie da sessão, que não diz nada sobre a hora.
Honeypot substitui CAPTCHA?
Para o bot genérico que varre a web preenchendo tudo que encontra, sim, e sem cobrar nada do visitante. Contra um ataque dirigido, escrito para o seu formulário específico, não: ali a isca vira só a primeira camada.
PHP

Leia também