Frena el spam en formularios de contacto

Frena el spam en formularios de contacto9 min de lectura

Protección antispam para formularios de TanStack Start con Turnstile

Protege un formulario de TanStack Start del spam: middleware de función para la IP del cliente, un token bucket, Turnstile verificado en una server function y una CSP.

Preet SutharFundador, Ikiform

Añade un middleware de función que lea una vez la IP del cliente y el origen y los pase a cada server function como contexto. En la server function, valida la entrada, toma un token de un bucket por IP, verifica el token de Turnstile con siteverify y reenvía los campos con el honeypot. Conserva el middleware CSRF que instala Start, y vuelve a añadirlo tú si creas src/start.ts.

Qué cambia en TanStack Start

Una server function es un endpoint RPC del mismo origen. Start la protege de otros sitios con un middleware CSRF que comprueba Sec-Fetch-Site, Origin o Referer, y eso impide que una página ajena envíe a través del navegador de un visitante. No frena a un script, que puede enviar las cabeceras que quiera. Así que las comprobaciones antispam siguen yendo dentro de la función.

La pieza propia de Start para esto es el middleware. El middleware de función se ejecuta antes que el validador y el handler, puede leer las cabeceras de la petición y entrega valores al handler mediante context. Escribe la búsqueda de IP una vez y todos los formularios del sitio la tendrán.

La cadena de middleware delante de la server function de TanStack StartAntes de tu códigoEn la server functionPOST/_serverFndesde elformularioo un scriptmiddlewareCSRF¿mismoorigen?middlewaredevisitantecontext:ip, originvalidadorrecorta,limitalongitudestokenbucket3 intentos,1 más cada20 ssiteverifyla actiones supportIkiformguarda,puntúa elhoneypotresultado:mástardesíqueda tokenhumanovacíoLa cadena de middleware delante de la server function de TanStack StartAntes de tu códigoEn la server functionPOST/_serverFndesde el formularioo un scriptmiddlewareCSRF¿mismo origen?middleware devisitantecontext: ip, originvalidadorrecorta, limitalongitudestoken bucket3 intentos, 1 máscada 20 ssiteverifyla action es supportIkiformguarda, puntúa elhoneypotresultado:mástardesíqueda tokenhumanovacío
  1. El navegador, o un script, envía a la URL de la server function.
  2. El middleware CSRF de Start rechaza los envíos que no puede demostrar que vienen de tu propio origen.
  3. El middleware de visitante lee la IP del cliente y la cabecera Origin y los pasa como contexto.
  4. El validador normaliza la entrada antes de que la vea el handler.
  5. El handler toma un token del bucket de esa IP. Si no quedan, devuelve un resultado de inténtalo más tarde.
  6. Verifica el token de Turnstile y luego reenvía los campos a Ikiform, que los guarda y puntúa el honeypot.
Cada capa es más barata que la siguiente. Un envío rechazado nunca llega a siteverify ni a Ikiform.

Configuración del proyecto

Archivos
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
  • Vite solo expone al navegador las variables con el prefijo VITE_, que se leen con import.meta.env. Los secretos no tienen prefijo, así que solo existen en el servidor como process.env.
  • Las claves de prueba pasan en cualquier host, incluido localhost. Sustitúyelas por las reales en cada entorno.

Middleware de función para la IP del cliente

src/server/visitor.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});

Pon en CLIENT_IP_HEADER la cabecera que sobrescribe tu host: x-forwarded-for en Vercel, x-nf-client-connection-ip en Netlify, que recomienda el equipo de Netlify aunque su documentación solo describe context.ip en Functions, y cf-connecting-ip en Cloudflare. En tu propio servidor Node detrás de nginx, usa la cabecera que pone nginx y asegúrate de que no se pueda llegar a la app sin pasar por él. El middleware también reenvía el Origin del visitante, porque el fetch del servidor a Ikiform no envía ninguno e Ikiform lo lee para reconocer las pruebas gratuitas desde localhost.

Un token bucket por IP

Una ventana fija permite a un script mandar un cupo completo al final de un minuto y otro al principio del siguiente. Un token bucket recarga un intento cada vez, así que quien corrige una errata espera segundos, no un minuto:

src/server/bucket.ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}

Este contador vive en memoria, lo que es exacto en un único servidor de larga duración, como node .output/server/index.mjs en un contenedor. En Vercel o Netlify cada instancia de función tiene su propio map, así que el límite real es desconocido. Ahí, lleva el bucket a Redis, como muestra la página de límites de frecuencia, y mantén la misma forma take(key).

La server function

src/server/support.ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
  • El validador se ejecuta antes que el handler y solo normaliza la entrada. Los errores de campo vuelven como un resultado que el formulario puede mostrar junto al input correspondiente.
  • Primero las comprobaciones baratas: las del email y la longitud no cuestan nada, el bucket cuesta una búsqueda en un map y siteverify cuesta una llamada de red.
  • action debe ser support. Se rechaza un token emitido para otro widget con tu site key.
  • El honeypot _gotcha se reenvía, no se comprueba aquí, así que uno relleno se guarda como spam donde puedes revisarlo.
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.

La ruta y el widget

El widget se renderiza de forma explícita desde un pequeño hook. Una promesa a nivel de módulo carga el script una sola vez, aunque la ruta se monte varias veces durante la navegación en el cliente, y el token llega mediante el callback de Turnstile:

src/lib/use-turnstile.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: Api }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
src/routes/support.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
  • El botón sigue desactivado hasta que Turnstile entrega un token, así que nadie envía uno vacío.
  • turnstile.reset() se ejecuta después de cada intento. Un token sirve una vez y dura 300 segundos, así que un reintento necesita uno nuevo.
  • Este formulario necesita JavaScript. Las server functions pueden recibir envíos de formulario normales mediante su .url, pero el widget de Turnstile necesita un navegador de todos modos.

Cabeceras de seguridad en src/start.ts

Start no tiene una opción de CSP propia. Un middleware de petición global en src/start.ts puede fijarla en cada respuesta. Crear ese archivo desactiva el middleware CSRF que Start instala por defecto, así que vuelve a añadirlo primero:

src/start.ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
  • El HTML renderizado en el servidor por Start incluye scripts en línea. Sin una configuración de nonces, script-src necesita 'unsafe-inline'; aun así bloquea los scripts de cualquier host que no hayas indicado.
  • Turnstile dibuja su widget en un iframe de challenges.cloudflare.com, que frame-src debe permitir.
  • El filter de CSRF limita la comprobación a las server functions, así que las cargas de página normales no se ven afectadas.

Despliegue

HostCLIENT_IP_HEADERLimitador
Tu propio servidor Node o contenedorLa que fije tu proxyEn memoria funciona
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipBinding de rate limiting de Workers

Vercel documenta que sobrescribe x-forwarded-for con la dirección del visitante. Indica cada hostname de producción y de preview en el widget de Turnstile antes de desplegar.

Deja que Ikiform haga las comprobaciones

Si no quieres gestionar tú las claves de Turnstile y un limitador, quita la server function y envía desde el navegador. En onSubmit, sustituye la llamada a send por un challenge y un submit, donde box es un ref a un div vacío que sustituye al widget:

src/routes/support.tsx
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });

Pon el Spam check del formulario en Ikiform, elige el Rate limit Strict de 2 cada 10 minutos por visitante y añade tu dominio en Allowed domains. Ikiform ejecuta entonces el honeypot, Turnstile en ikiform.com, el límite por IP y la puntuación del contenido, y conserva lo que atrapa durante 30 días. Tu CSP necesita https://ikiform.com en frame-src y connect-src en lugar de Cloudflare. La guía de configuración de servidor cubre las claves si mantienes la server function.

Comprueba que funciona

  1. Ejecuta el servidor de desarrollo con las claves de prueba y envía una petición. Aparece en Submissions. Sin plan, 50 pruebas desde localhost al mes son gratis.
  2. Envía cuatro peticiones rápido. La cuarta devuelve el mensaje de inténtalo más tarde con un número de segundos.
  3. Pon TURNSTILE_SECRET_KEY con el secreto que siempre falla. Cada petición muestra el error de verificación humana.
  4. Repite la llamada a la server function desde la pestaña de red con curl y sin Origin. El middleware CSRF la rechaza.
  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
Los envíos cross-site llegan a las server functions después de añadir src/start.tsUn src/start.ts personalizado sustituye el middleware CSRF por defecto. Añade createCsrfMiddleware() a requestMiddleware, como se muestra.
Todos los visitantes comparten un mismo bucketCLIENT_IP_HEADER indica una cabecera que tu host no fija, así que todas las IP son unknown. Usa la cabecera de la tabla de arriba.
El límite parece reiniciarse al azar en VercelCada instancia de función tiene su propia memoria. Lleva el bucket a Redis.
El widget se renderiza dos veces en desarrolloEl Strict Mode de React monta los effects dos veces. El cleanup elimina el primer widget, así que en producción se ve uno.

Preguntas

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