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.
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 chamadospam.server.tsnã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çãoheadersda 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 umakeyapó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.
- Depois da hidratação, o formulário envia por useFetcher, então a página não navega.
- A action limita a taxa por IP, valida o token do Turnstile e repassa os campos.
- O resultado chega como fetcher.data.
- A key do widget muda, então um widget novo, e um token novo, fica pronto para a próxima tentativa.
- Antes da hidratação, o mesmo formulário envia como uma requisição de documento normal, sem token do Turnstile.
- A action o recusa no siteverify.
- O export headers repassa o Retry-After e a CSP para a resposta de documento.
- O componente lê actionData em vez de fetcher.data e mostra o erro.
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:
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { 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>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}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.varslocalmente e emwrangler secret putem produção, nunca emwrangler.jsonc. - Rode
wrangler typesdepois de editar a configuração, para queCONTACT_LIMITe os segredos fiquem tipados emenv.
Os helpers só do servidor
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
.serverfaz 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.
forwardenvia oFormDataoriginal, então todo campo, inclusive o honeypot, chega ao Ikiform como foi enviado.- Ele também repassa o
Origindo visitante, porque um fetch do lado do servidor não envia nenhum e o Ikiform o lê para reconhecer testes gratuitos emlocalhost.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O widget
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
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 oRetry-After. Um fetcher lê o corpo diretamente; é o exportheadersque leva esses cabeçalhos para uma resposta de documento.resultrecorre aactionData, então o erro ainda aparece quando o formulário enviou antes da hidratação.- A classe
.hpesconde 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
| Host | IP do cliente | Limitador | Segredos |
|---|---|---|---|
| Cloudflare Workers | cf-connecting-ip | Binding de rate limiting | wrangler secret put |
| Vercel | x-forwarded-for, sobrescrito pela Vercel | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.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:
<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
- Rode
npm run deve envie uma mensagem com as chaves de teste. Ela aparece em Submissions. Um workspace sem plano aceita 50 testes por mês a partir delocalhost. - Envie uma segunda mensagem sem recarregar. Um widget novo aparece após a primeira resposta e o segundo envio funciona.
- Desative o JavaScript e envie. A página recarrega com o erro da verificação humana.
- Depois do deploy, envie seis vezes em um minuto. O sexto recebe
429comRetry-After: 60. - 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
| Problema | O que fazer |
|---|---|
Server-only module referenced by client | Um componente importa de spam.server.ts algo que ele renderiza. Só loader e action podem usá-lo. |
| A segunda tentativa sempre falha no siteverify | O widget manteve o token já usado. Garanta que a key dele mude após cada resposta do fetcher. |
Invalid context value provided to handleRequest | A 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 JavaScript | Os cabeçalhos da action são descartados a menos que o export headers os retorne. Copie-os de actionHeaders como mostrado. |
Perguntas
Renderize o widget no componente da rota, leia cf-turnstile-response dos dados do formulário na action e envie-o com sua chave secreta ao siteverify. Só continue se a resposta disser success com a action que você definiu no widget.
Declare um binding de rate limiting em wrangler.jsonc e chame limit com uma chave montada a partir do cabeçalho cf-connecting-ip no topo da action. Retorne um 429 com data() quando falhar.
Numa requisição de documento, o React Router só envia os cabeçalhos que o export headers da sua rota retorna. Leia actionHeaders ali e copie o que precisar, como o Retry-After.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.