Acabe com o spam no formulário de contato

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

Proteção contra spam em formulários React Router com Turnstile

Proteja um formulário React Router contra spam: action de rota com Turnstile, rate limit no Workers por cf-connecting-ip, um módulo .server e um cabeçalho de CSP.

Preet SutharFundador, Ikiform

Faça as verificações na action da rota: limite pelo IP do cliente, valide o token do Turnstile com o siteverify e depois repasse os campos com o honeypot. Mantenha os helpers num módulo .server.ts para que nunca cheguem ao navegador, e defina a CSP no export headers da rota. No Cloudflare Workers, o binding de rate limiting e os segredos vêm de env em cloudflare:workers.

O que muda no React Router

No framework mode, toda rota com action aceita POSTs, do seu formulário ou de qualquer outra pessoa. O React Router dá três ferramentas que moldam como as verificações de spam se encaixam:

  • Módulos .server. Um arquivo chamado spam.server.ts não pode entrar no bundle do cliente, então segredos e chamadas ao siteverify ficam no servidor mesmo quando um componente importa da mesma rota.
  • O export headers. Cabeçalhos que uma action retorna não são enviados numa resposta de documento, a menos que a função headers da rota os repasse. A CSP também fica ali.
  • useFetcher. Ele envia à action sem navegação, então a página e o widget continuam montados entre tentativas. Por isso o widget é remontado com uma key após cada resposta.

Esta página faz deploy no Cloudflare Workers, uma casa comum para o React Router, onde há um binding de rate limiting embutido. A tabela de deploy cobre Vercel e Netlify.

Como um envio chega à action do React Router, com e sem JavaScriptHidratado: useFetcherAntes da hidratação: um POST de documentofetcher.Formsemnavegaçãoactionlimite,siteverify,repassefetcher.dataerro ouobrigadowidgetnovokey mudaenvio deformuláriosimplesnenhumwidgetrenderizadoactionsiteverifyrecusaexportheadersCSP,Retry-AfteractionDataerro exibidoPOSTdata()remontaPOSTrenderizaComo um envio chega à action do React Router, com e sem JavaScriptHidratado: useFetcherAntes da hidratação: um POST de documentofetcher.Formsem navegaçãoactionlimite, siteverify, repassefetcher.dataerro ou obrigadowidget novokey mudaenvio de formuláriosimplesnenhum widget renderizadoactionsiteverify recusaexport headersCSP, Retry-AfteractionDataerro exibidoPOSTdata()remontaPOSTrenderiza
  1. Depois da hidratação, o formulário envia por useFetcher, então a página não navega.
  2. A action limita a taxa por IP, valida o token do Turnstile e repassa os campos.
  3. O resultado chega como fetcher.data.
  4. A key do widget muda, então um widget novo, e um token novo, fica pronto para a próxima tentativa.
  5. Antes da hidratação, o mesmo formulário envia como uma requisição de documento normal, sem token do Turnstile.
  6. A action o recusa no siteverify.
  7. O export headers repassa o Retry-After e a CSP para a resposta de documento.
  8. O componente lê actionData em vez de fetcher.data e mostra o erro.
Uma verificação humana precisa de JavaScript, então o caminho sem JavaScript termina num erro claro em vez de passar em silêncio.

Configuração do projeto

Comece pelo template React Router da Cloudflare com @cloudflare/vite-plugin. No React Router 8 o argumento context é sempre um RouterContextProvider, e um objeto simples { cloudflare: { env } } vindo da entrada do Worker lança erro, então a entrada passa só a requisição e a rota lê os bindings com import { env } from "cloudflare:workers". Os arquivos que esta página mexe:

Arquivos
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
workers/app.ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
wrangler.jsonc
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
.dev.vars
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
  • A site key é pública, então fica em vars. O loader a entrega ao componente, então o build do cliente não precisa de variável de ambiente nenhuma.
  • Os segredos vão em .dev.vars localmente e em wrangler secret put em produção, nunca em wrangler.jsonc.
  • Rode wrangler types depois de editar a configuração, para que CONTACT_LIMIT e os segredos fiquem tipados em env.

Os helpers só do servidor

app/lib/spam.server.ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
  • O sufixo .server faz o build falhar se o código do cliente fosse incluir este módulo.
  • O siteverify aceita corpos form-encoded. A verificação de hostname compara com o host para onde o visitante enviou e é pulada em desenvolvimento, porque tokens de teste trazem um hostname fictício.
  • forward envia o FormData original, então todo campo, inclusive o honeypot, chega ao Ikiform como foi enviado.
  • Ele também repassa o Origin do visitante, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos em localhost.
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.

O widget

app/components/turnstile.tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}

O React 19 sobe um <script> async para o head e o carrega uma vez, não importa quantas vezes o componente renderize. O effect renderiza o widget explicitamente, como a Cloudflare recomenda para apps de página única, e o remove ao desmontar.

A rota

app/routes/contact.tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

const POLICY = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
  • O limite roda antes de request.formData(), então uma enxurrada nunca custa a leitura do corpo nem uma chamada ao siteverify.
  • cf-connecting-ip é definido pela Cloudflare em toda requisição a um Worker e não pode ser definido pelo visitante.
  • data() define o status e o Retry-After. Um fetcher lê o corpo diretamente; é o export headers que leva esses cabeçalhos para uma resposta de documento.
  • result recorre a actionData, então o erro ainda aparece quando o formulário enviou antes da hidratação.
  • A classe .hp esconde o honeypot fora da tela, como no guia do honeypot.

O React Router renderiza scripts inline para a hidratação, então esta política mantém 'unsafe-inline' em script-src. Para tirá-lo, gere um nonce em entry.server.tsx e passe-o a <Scripts> e ao script do Turnstile; a Cloudflare diz que o Turnstile repassa o nonce ao que ele carrega.

Teste localmente

A documentação da Cloudflare diz que o plugin do Vite roda seu código de servidor no runtime do Workers durante o desenvolvimento, então env de cloudflare:workers, .dev.vars e o binding de rate limiting se comportam como em produção. Localmente nenhuma borda da Cloudflare define cf-connecting-ip, então toda requisição conta como unknown e compartilha um único limite. Isso é esperado. Para ver o cabeçalho real, publique um preview com wrangler versions upload e teste a partir de duas redes.

Deploy em outros lugares

HostIP do clienteLimitadorSegredos
Cloudflare Workerscf-connecting-ipBinding de rate limitingwrangler secret put
Vercelx-forwarded-for, sobrescrito pela VercelRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

A Cloudflare documenta o binding como aproximado e contado por localidade, o que serve bem para um formulário de contato. Fora do Workers não existe o módulo cloudflare:workers, então leia os segredos de process.env e troque o binding por um contador com Redis como o da página de limite de taxa.

Deixe o Ikiform rodar as verificações

Se manter chaves, um binding e uma CSP para um único formulário for mais do que você quer, aponte um formulário simples para o seu endpoint do Ikiform e apague a action:

app/routes/contact.tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>

Use um <form> minúsculo, não o <Form> do React Router, para que o navegador envie direto ao Ikiform. Defina Spam check como Ikiform: um envio sem token recebe uma breve página de verificação em ikiform.com que roda o Turnstile e repassa os campos. Escolha o Rate limit Strict, 2 a cada 10 minutos por visitante, e liste seu domínio em Allowed domains. O spam barrado recebe a mesma resposta que uma mensagem real e fica guardado por 30 dias. O guia de configuração de servidor cobre como manter sua própria action.

Confira se funciona

  1. Rode npm run dev e envie uma mensagem com as chaves de teste. Ela aparece em Submissions. Um workspace sem plano aceita 50 testes por mês a partir de localhost.
  2. Envie uma segunda mensagem sem recarregar. Um widget novo aparece após a primeira resposta e o segundo envio funciona.
  3. Desative o JavaScript e envie. A página recarrega com o erro da verificação humana.
  4. Depois do deploy, envie seis vezes em um minuto. O sexto recebe 429 com Retry-After: 60.
  5. 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.

Pegadinhas

ProblemaO que fazer
Server-only module referenced by clientUm componente importa de spam.server.ts algo que ele renderiza. Só loader e action podem usá-lo.
A segunda tentativa sempre falha no siteverifyO widget manteve o token já usado. Garanta que a key dele mude após cada resposta do fetcher.
Invalid context value provided to handleRequestA entrada do Worker ainda passa { cloudflare: { env, ctx } }, como faziam os templates do React Router 7. Chame o handler só com a requisição e leia os bindings de cloudflare:workers.
Retry-After não aparece na resposta sem JavaScriptOs cabeçalhos da action são descartados a menos que o export headers os retorne. Copie-os de actionHeaders como mostrado.

Perguntas

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