Frena el spam en formularios de contacto

Frena el spam en formularios de contacto8 min de lectura

Protección antispam para formularios de React Router con Turnstile

Protege un formulario de React Router del spam: una action de ruta con Turnstile, un límite en Workers basado en cf-connecting-ip, un módulo .server y una cabecera CSP.

Preet SutharFundador, Ikiform

Haz las comprobaciones en la action de la ruta: limita por la IP del cliente, verifica el token de Turnstile con siteverify y luego reenvía los campos con el honeypot. Mantén los helpers en un módulo .server.ts para que nunca lleguen al navegador, y fija la CSP desde el export headers de la ruta. En Cloudflare Workers, el binding de rate limiting y los secretos vienen de env en cloudflare:workers.

Qué cambia en React Router

En el modo framework, cada ruta con una action acepta POSTs, de tu formulario o de cualquier otro. React Router te da tres herramientas que determinan cómo encajan las comprobaciones antispam:

  • Módulos .server. Un archivo llamado spam.server.ts no puede empaquetarse en el código de cliente, así que los secretos y las llamadas a siteverify se quedan en el servidor aunque un componente importe desde la misma ruta.
  • El export headers. Las cabeceras que devuelve una action no se envían en una respuesta de documento salvo que la función headers de la ruta las pase. La CSP también vive ahí.
  • useFetcher. Envía a la action sin navegación, así que la página y su widget siguen montados entre intentos. Por eso el widget se vuelve a montar con una key después de cada respuesta.

Esta página despliega en Cloudflare Workers, un hogar habitual de React Router, donde hay un binding de rate limiting integrado. La tabla de despliegue cubre Vercel y Netlify.

Cómo llega un envío a la action de React Router, con y sin JavaScriptHidratado: useFetcherAntes de la hidratación: un POST de documentofetcher.Formsinnavegaciónactionlímite,siteverify,reenvíofetcher.dataerror ograciaswidgetnuevocambia la keyenvío deformularionormalsin widgetrenderizadoactionsiteverifyrechazaexportheadersCSP,Retry-AfteractionDatase muestra elerrorPOSTdata()remontarPOSTrenderizarCómo llega un envío a la action de React Router, con y sin JavaScriptHidratado: useFetcherAntes de la hidratación: un POST de documentofetcher.Formsin navegaciónactionlímite, siteverify, reenvíofetcher.dataerror o graciaswidget nuevocambia la keyenvío de formularionormalsin widget renderizadoactionsiteverify rechazaexport headersCSP, Retry-AfteractionDatase muestra el errorPOSTdata()remontarPOSTrenderizar
  1. Una vez hidratado, el formulario envía mediante useFetcher, así que la página no navega.
  2. La action limita la frecuencia por IP, verifica el token de Turnstile y reenvía los campos.
  3. Su resultado llega como fetcher.data.
  4. La key del widget cambia, así que hay un widget nuevo, y un token nuevo, listo para el siguiente intento.
  5. Antes de la hidratación, el mismo formulario envía como una petición de documento normal, sin token de Turnstile.
  6. La action lo rechaza en siteverify.
  7. El export headers pasa Retry-After y la CSP a la respuesta del documento.
  8. El componente lee actionData en lugar de fetcher.data y muestra el error.
Una verificación humana necesita JavaScript, así que el camino sin JavaScript termina en un error claro en lugar de pasar en silencio.

Configuración del proyecto

Parte de la plantilla de React Router de Cloudflare con @cloudflare/vite-plugin. En React Router 8 el argumento context siempre es un RouterContextProvider, y un objeto { cloudflare: { env } } sin más desde la entrada del Worker lanza un error, así que la entrada pasa solo la petición y la ruta lee los bindings con import { env } from "cloudflare:workers". Los archivos que toca esta página:

Archivos
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
  • La site key es pública, así que va en vars. El loader se la pasa al componente, así que el build del cliente no necesita ninguna variable de entorno.
  • Los secretos van en .dev.vars en local y con wrangler secret put en producción, nunca en wrangler.jsonc.
  • Ejecuta wrangler types después de editar la configuración, para que CONTACT_LIMIT y los secretos tengan tipo en env.

Los helpers solo de 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;
}
  • El sufijo .server hace que el build falle si el código de cliente fuera a incluir este módulo.
  • Siteverify acepta cuerpos form-encoded. La comprobación del hostname compara con el host al que envió el visitante y se omite en desarrollo, porque los tokens de prueba llevan un hostname ficticio.
  • forward envía el FormData original, así que todos los campos, incluido el honeypot, llegan a Ikiform tal como se enviaron.
  • También reenvía el Origin del visitante, porque un fetch del lado del servidor no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde localhost.
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains y su verificación humana. Todos los envíos llegan entonces desde la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo de servidor.

El 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} />
    </>
  );
}

React 19 sube un <script> async al head y lo carga una sola vez, por muchas veces que se renderice el componente. El effect renderiza el widget de forma explícita, como recomienda Cloudflare para single-page apps, y lo elimina al desmontar.

La ruta

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>
  );
}
  • El límite se ejecuta antes de request.formData(), así que un flood nunca cuesta leer un cuerpo ni una llamada a siteverify.
  • Cloudflare fija cf-connecting-ip en cada petición a un Worker y el visitante no puede fijarla.
  • data() fija el estado y Retry-After. Un fetcher lee el cuerpo directamente; el export headers es lo que lleva esas cabeceras a una respuesta de documento.
  • result recurre a actionData, así que el error se muestra igualmente cuando el formulario se envió antes de la hidratación.
  • La clase .hp oculta el honeypot fuera de pantalla, como en la guía del honeypot.

React Router renderiza scripts en línea para la hidratación, así que esta política mantiene 'unsafe-inline' en script-src. Para quitarlo, genera un nonce en entry.server.tsx y pásalo a <Scripts> y al script de Turnstile; Cloudflare indica que Turnstile pasa el nonce a lo que carga.

Pruébalo en local

La documentación de Cloudflare indica que su plugin de Vite ejecuta tu código de servidor en el runtime de Workers durante el desarrollo, así que env de cloudflare:workers, .dev.vars y el binding de rate limiting se comportan como lo harán en producción. En local ningún edge de Cloudflare fija cf-connecting-ip, así que todas las peticiones cuentan como unknown y comparten un mismo límite. Es lo esperado. Para ver la cabecera real, despliega un preview con wrangler versions upload y prueba desde dos redes.

Desplegar en otro lugar

HostIP del clienteLimitadorSecretos
Cloudflare Workerscf-connecting-ipBinding de rate limitingwrangler secret put
Vercelx-forwarded-for, sobrescrita por VercelRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

Cloudflare documenta el binding como aproximado y contado por ubicación, lo que vale para un formulario de contacto. Fuera de Workers no existe el módulo cloudflare:workers, así que lee los secretos de process.env y cambia el binding por un contador en Redis como el de la página de límites de frecuencia.

Deja que Ikiform haga las comprobaciones

Si gestionar claves, un binding y una CSP para un solo formulario es más de lo que quieres, apunta un formulario básico a tu endpoint de Ikiform y elimina la 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>

Usa un <form> en minúscula, no el <Form> de React Router, para que el navegador envíe directamente a Ikiform. Pon Spam check en Ikiform: un envío sin token recibe una breve página de verificación en ikiform.com que ejecuta Turnstile y reenvía los campos. Elige el Rate limit Strict, 2 cada 10 minutos por visitante, e indica tu dominio en Allowed domains. El spam atrapado recibe la misma respuesta que un mensaje real y se conserva 30 días. La guía de configuración de servidor explica cómo mantener tu propia action.

Comprueba que funciona

  1. Ejecuta npm run dev y envía un mensaje con las claves de prueba. Aparece en Submissions. Un espacio de trabajo sin plan acepta 50 pruebas desde localhost al mes.
  2. Envía un segundo mensaje sin recargar. Aparece un widget nuevo tras la primera respuesta y el segundo envío funciona.
  3. Desactiva JavaScript y envía. La página se recarga con el error de verificación humana.
  4. Tras desplegar, envía seis veces en un minuto. El sexto recibe 429 con Retry-After: 60.
  5. Envía con el honeypot relleno, _gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

Trampas habituales

ProblemaQué hacer
Server-only module referenced by clientUn componente importa de spam.server.ts algo que renderiza. Solo loader y action pueden usarlo.
El segundo intento siempre falla en siteverifyEl widget conservó su token usado. Asegúrate de que su key cambia después de cada respuesta del fetcher.
Invalid context value provided to handleRequestLa entrada del Worker sigue pasando { cloudflare: { env, ctx } }, como hacían las plantillas de React Router 7. Llama al handler solo con la petición y lee los bindings de cloudflare:workers.
Falta Retry-After en la respuesta sin JavaScriptLas cabeceras de la action se descartan salvo que el export headers las devuelva. Cópialas de actionHeaders como se muestra.

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.