Acabe com o spam no formulário de contato

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

Como adicionar o Cloudflare Turnstile a um formulário HTML

Adicione o Cloudflare Turnstile a um formulário HTML simples: o widget, o siteverify no servidor, chaves de teste e um jeito de rodá-lo sem chaves nem servidor próprio.

Preet SutharFundador, Ikiform

Adicione o script do Turnstile e um elemento cf-turnstile dentro do formulário; o widget coloca um token num campo oculto cf-turnstile-response. Depois envie esse token e sua chave secreta ao endpoint siteverify da Cloudflare a partir de um servidor, e aceite o envio só se a resposta for success. Sem servidor, deixe seu backend de formulários validá-lo, ou use a verificação gerenciada do Ikiform, que não precisa de chave nenhuma.

O que muda com o Turnstile

O Turnstile tem duas metades, e um formulário HTML simples só tem espaço para uma. O widget roda no navegador com sua site key pública e gera um token. A verificação roda num servidor com sua secret key, que nunca pode chegar à página. Um formulário que mostra o widget mas nunca valida o token é enfeite: um bot envia direto para o seu endpoint e pula o widget por completo.

Então a pergunta real para um formulário HTML é onde fica o segredo. Esta página cobre três respostas: seu próprio servidor pequeno, um backend de formulários que guarda suas chaves e uma verificação gerenciada em que só o backend tem chaves.

A vida de um token do Turnstile, do widget ao vereditoNo navegador, com a site keyNo servidor, com a chave secretawidget rodaquase sempreinvisíveltoken emitidocampo ocultocf-turnstile-responseformulárioenviadotoken vai com oscampossiteverifysegredo + token + IPdo visitanteconfere arespostasuccess, hostname,actionaceita o enviorecusaenviartudo batealgo erradoA vida de um token do Turnstile, do widget ao vereditoNo navegador, com a site keyNo servidor, com a chave secretawidget rodaquase sempreinvisíveltoken emitidocampo ocultocf-turnstile-responseformulárioenviadotoken vai com oscampossiteverifysegredo + token +IP do visitanteconfere arespostasuccess, hostname,actionaceita o enviorecusaenviartudo batealgo errado
  1. O widget do Turnstile roda no navegador com sua site key pública. A maioria dos visitantes nunca o vê fazer nada.
  2. Ele emite um token e o coloca num campo oculto cf-turnstile-response dentro do formulário.
  3. O formulário é enviado, e o token vai junto com os outros campos.
  4. Seu servidor envia a chave secreta, o token e o IP do visitante ao endpoint siteverify da Cloudflare.
  5. Ele confere se a resposta diz success e se o hostname e a action são os esperados.
  6. Se tudo bater, o envio é aceito. Se algo estiver errado, é recusado.
Um token vale por 300 segundos e só pode ser validado uma vez. Uma segunda verificação do mesmo token falha com timeout-or-duplicate.

Adicione o widget

Crie um widget no painel da Cloudflare em Turnstile, liste os hostnames onde seu formulário roda e copie a site key. Depois adicione o script e um elemento cf-turnstile dentro do formulário. O elemento precisa estar dentro do <form>, senão o campo oculto do token fica fora dele e nunca é enviado.

contact.html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  • data-action="contact" rotula o widget. O siteverify o devolve, então seu servidor pode recusar um token emitido para outro formulário.
  • O script precisa ser carregado da URL da própria Cloudflare. A Cloudflare diz que uma cópia via proxy ou hospedada por você quebra o widget.
  • Se seu site envia uma Content Security Policy, permita https://challenges.cloudflare.com tanto em script-src quanto em frame-src.
  • Sem custo, a Cloudflare permite até 20 widgets, cada um em até 10 hostnames, então liste também os hosts de staging.

Valide o token no seu próprio servidor

Qualquer servidor pode validar um token: um POST ao siteverify com o segredo e o token. Aqui está como um Cloudflare Worker para o qual um formulário estático envia. Ele confere o token, o hostname e a action, descarta o token e repassa os campos ao Ikiform com uma chave secreta. Troque o repasse por qualquer outra coisa que guarde ou envie a mensagem por e-mail.

src/index.ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
  • O siteverify aceita corpos JSON ou form-encoded e sempre responde em JSON. remoteip é opcional; idempotency_key permite repetir uma verificação com segurança após um erro de rede.
  • Confira hostname e action, não só success. Um token emitido na página de outra pessoa com a sua site key também diz success.
  • Guarde os dois segredos com wrangler secret put, nunca em wrangler.toml.
  • O Worker responde com um redirecionamento 303, então o formulário simples não precisa de JavaScript. Aponte o action do formulário para a rota do Worker.
  • Ele repassa o cabeçalho Origin da página, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em localhost.

Para mostrar o erro na página do formulário após uma verificação que falhou, leia a query string que o Worker define:

contact.html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
As versões do lado do servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana dele. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário enviado por um servidor e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

Deixe o Ikiform validar

Se você não quer rodar servidor nenhum, o formulário pode enviar direto ao Ikiform e o Ikiform faz a chamada ao siteverify. A opção Spam check de cada formulário decide como.

CaminhoChaves que você gerenciaWidget na sua páginaServidor próprio
Seu próprio servidor ou Worker, acimaSite key e chave secretaSim, o seuSim
Ikiform com Your own Turnstile keysSite key e chave secreta, coladas no IkiformSim, o seuNão
Ikiform com IkiformNenhumNão. Uma página de verificação em ikiform.com, ou um pequeno frameNão

Com Your own Turnstile keys, cole a site key e a chave secreta nas configurações do formulário. O segredo é criptografado em repouso. Mantenha o mesmo markup do widget, mas aponte o formulário para o seu endpoint do Ikiform. O Ikiform valida cf-turnstile-response e confere o hostname do token com os Allowed domains do formulário. Um envio sem token válido recebe 403 captcha_failed, mesmo com uma chave de API secreta.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Com Ikiform, você não adiciona nada à página. Um envio HTML simples sem token recebe uma breve página Checking you're human (verificando se você é humano) em ikiform.com que roda o Turnstile e repassa os campos. Um envio com fetch recebe 403 challenge_required em vez disso; chame challenge() de @ikiform/sdk para obter um token antes. Nenhuma conta da Cloudflare entra na história. A documentação de spam mostra os dois fluxos.

Se a Cloudflare não responder, o Ikiform não descarta o envio: ele o guarda como spam com o motivo captcha_unavailable, para você poder restaurá-lo pelo painel. Seu próprio servidor deve tomar essa mesma decisão de forma deliberada, ou falhando de forma fechada como o Worker acima, ou mantendo o envio para revisão.

Chaves de teste

A Cloudflare publica chaves fictícias que funcionam em qualquer hostname, inclusive localhost. Use-as enquanto desenvolve e depois troque pelas suas chaves reais.

ChaveValorComportamento
Site key1x00000000000000000000AASempre passa, widget visível
Chave secreta1x0000000000000000000000000000000AAO siteverify sempre responde sucesso
Chave secreta2x0000000000000000000000000000000AAO siteverify sempre responde falha

Tokens de teste não trazem seu hostname real, então a verificação de hostname do Worker os recusa. Afrouxe essa verificação só no seu ambiente de desenvolvimento, nunca em produção.

Confira se funciona

  1. Envie o formulário normalmente. O envio é aceito e o widget não mostrou nada, ou só uma caixa de seleção.
  2. Remova o elemento cf-turnstile e envie. O envio é recusado: o Worker redireciona com ?error=check, ou o Ikiform responde 403 captcha_failed.
  3. Envie, volte e envie a mesma página de novo. O token reutilizado falha, o que mostra que o servidor realmente o valida.
  4. Abra Submissions no painel. O novo envio está lá com todos os campos que você mandou.
  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

  • Um token, uma tentativa. Após um envio com falha tratado com fetch, chame turnstile.reset() para obter um token novo. Um recarregamento completo da página já traz um automaticamente.
  • Quem digita devagar. Tokens expiram após 300 segundos. A opção data-refresh-expired do widget decide se ele busca um novo sozinho ou espera um clique.
  • www e o domínio raiz. www.example.com e example.com são hostnames diferentes para o siteverify. Compare com aquele em que seu formulário realmente roda, ou permita os dois.
  • Navegação de página única. O script implícito renderiza os widgets uma vez, quando carrega. Numa troca de rota no cliente, renderize explicitamente com ?render=explicit e turnstile.render().
  • Envios de servidor com as chaves do próprio Ikiform. Se seu servidor repassa ao Ikiform com uma chave secreta, o Ikiform pula a verificação gerenciada dele. Valide o token você mesmo antes, como o Worker faz.

Perguntas

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