Acabe com o spam no formulário de contato

Acabe com o spam no formulário de contato6 min de leitura

Como acabar com o spam em formulários sem captcha

Barre a maior parte do spam em formulários sem captcha nenhum: honeypot, horário de renderização assinado, pontuação de conteúdo e limite de taxa, somados em vez de usados sozinhos.

Preet SutharFundador, Ikiform

Combine quatro verificações invisíveis e some o que elas encontram: um honeypot, um timestamp assinado que mostra por quanto tempo o formulário ficou aberto, uma pontuação para links e frases de spam e um limite de taxa por visitante. Trate um envio como spam só quando o total passar de um limite, para que nenhum sinal leve isolado bloqueie uma pessoa real. Guarde o que você barrar e adicione um captcha depois só se a pasta de spam mostrar que bots ainda passam.

O que muda sem captcha

Um captcha dá um sim ou não forte. Sem ele, você tem vários sinais fracos, e cada um sozinho é fácil de driblar ou de disparar por acidente. Um envio rápido pode ser um bot, ou alguém que colou uma mensagem pronta. Um link pode ser spam, ou um portfólio. O segredo é pontuar: dê um peso a cada sinal, some tudo e só chame de spam acima de um limite. O único sinal forte, um honeypot preenchido, recebe um peso grande o bastante para cruzar a linha sozinho.

Como sinais invisíveis somam um veredito para três envios diferentesUm bot de scriptUma oferta de SEO coladaUma mensagem real com três linkshoneypotpreenchido+5enviado em 0,4 s+38: spamlink no nome+3"seo services"+25: spamtrês links+2nada mais2: recebidoComo sinais invisíveis somam um veredito para três envios diferentesUm bot de scriptUma oferta de SEO coladaUma mensagem real com três linkshoneypot preenchido+5enviado em 0,4 s+38: spamlink no nome+3"seo services"+25: spamtrês links+2nada mais2: recebido
  1. Um bot de script preenche o honeypot, o que soma 5, e envia em menos de um segundo, o que soma 3. O total de 8 é spam.
  2. Uma oferta de SEO colada tem um link no campo de nome, 3, e uma frase de spam conhecida, 2. O total de 5 atinge o limite de 5.
  3. Uma mensagem real com três links soma 2 e nada mais, então é recebida.
Os pesos são os do Ikiform, com limite de 5. Dois sinais leves juntos o cruzam; um só, nunca.

Um horário de renderização assinado

Pessoas precisam de vários segundos para preencher um formulário. Uma armadilha de tempo mede esse intervalo. A versão fraca coloca Date.now() num campo oculto, que qualquer bot pode reescrever. A versão forte assina o horário de renderização no servidor com um segredo, então um bot não consegue alegar que a página foi aberta antes do que realmente foi. Este arquivo roda em qualquer lugar com Web Crypto: Node 20 ou mais recente, Deno, Bun, Workers e todo runtime serverless.

lib/render-token.ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}

Assine quando a página for renderizada, então a página precisa ser renderizada a cada requisição, não ficar em cache como HTML estático. No Astro:

src/pages/contact.astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
  • Três segundos é um mínimo seguro para um formulário de contato. Alguém colando uma mensagem pronta ainda pode levar dois.
  • Rejeite tokens com mais de um dia. Senão um bot carrega a página uma vez e reutiliza o mesmo token por semanas.
  • Trate um token ausente ou forjado como um sinal, não como prova. Uma página em cache ou uma aba antiga podem gerar um.

Pontue o envio

Junte os sinais numa função só. As verificações de links e frases pegam o que o honeypot e o tempo não pegam: mensagens digitadas ou coladas por pessoas. Mantenha a lista de frases curta e específica; palavras amplas como free marcam clientes reais.

lib/score.ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
src/pages/api/contact.ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};

O handler guarda tudo com um status em vez de descartar o spam, e responde aos dois com o mesmo redirecionamento. Você revisa o spam depois, e um bot nunca descobre qual sinal o pegou. Adicione um limite de taxa por visitante na frente desta rota para barrar enxurradas antes que qualquer parte disso rode.

O Ikiform roda isso sem captcha

Todo formulário do Ikiform roda o honeypot, o tempo, a pontuação de conteúdo e os limites de taxa sem configuração, e o Spam check dele começa em Off. O honeypot é _gotcha. A pontuação de conteúdo confere links, 22 frases de spam conhecidas, domínios de e-mail descartáveis e repetições idênticas em 24 horas. O limite de taxa começa em 5 por minuto por visitante. Com 5 ou mais, um envio é spam.

SinalPeso
Honeypot preenchido+5
Enviado menos de 2 segundos após a renderização assinada+3
Horário de renderização forjado ou com mais de 24 horas+3
Um link num campo de nome ou assunto+3
Campos idênticos em 24 horas+3
Três ou mais links+2
Uma frase de spam conhecida+2
Um domínio de e-mail descartável+2

O horário de renderização assinado do Ikiform vem do schema público do formulário. O componente shadcn/ui o envia por você. Um formulário que você mesmo renderiza pode buscá-lo com getSchema() de @ikiform/sdk e devolvê-lo como _ikiform_ts:

contact.js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});

Formulários HTML simples não enviam horário de renderização e não são penalizados por isso, então a ausência dele não custa nada. Um envio julgado como spam recebe a mesma resposta de sucesso que um real, então o bot não aprende nada. Ele é guardado com o status spam, não dispara e-mail de notificação nem webhook, não conta no seu uso e é apagado após 30 dias. Toda segunda-feira, um workspace que bloqueou spam recebe um único e-mail de resumo com o total, os formulários mais visados e os principais motivos. O endpoint de schema e a documentação de spam têm os detalhes.

Se alguns bots ainda passarem, mude Spam check para Ikiform. Isso adiciona o Cloudflare Turnstile sem chaves, e a maioria dos visitantes nem percebe. É esse o momento de adicionar um captcha: quando a pasta de spam pedir, não antes.

Confira se funciona

  1. Carregue o formulário e envie em menos de um segundo. Com seu próprio handler, o envio é guardado como spam com too_fast.
  2. Edite o valor oculto rendered nas ferramentas de desenvolvedor do navegador e envie. Ele é pontuado como invalid_render.
  3. Envie com o honeypot preenchido, _gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook.
  4. Envie uma mensagem com três links e nada mais suspeito. Ela é recebida.
  5. Nada chegou? Mude o filtro de status do painel para Spam. O spam recebe a mesma resposta de sucesso que um envio real.

Pegadinhas

  • Páginas estáticas. Uma página gerada uma vez no deploy tem um único horário de renderização para todos os visitantes. Assine a cada requisição, ou busque um token novo com JavaScript quando o formulário aparecer.
  • Gerenciadores de senha e colar. Algumas pessoas preenchem um formulário em um segundo com o preenchimento automático. Mantenha o tempo como um sinal leve, para que ele nunca bloqueie sozinho.
  • Listas de frases envelhecem. As frases de spam mudam. Revise o que passa a cada poucas semanas e adicione a frase específica, nunca uma palavra comum.
  • Repetições são normais. As pessoas reenviam quando não recebem resposta. Dê peso a uma repetição idêntica, mas não a bloqueie de vez.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.