Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen7 Min. Lesezeit

Spamschutz für React-Router-Formulare mit Turnstile

Schütze ein React-Router-Formular vor Spam: eine Route-Action mit Turnstile, ein Workers-Rate-Limit nach cf-connecting-ip, ein .server-Modul und ein CSP-Header.

Preet SutharGründer, Ikiform

Führe die Prüfungen in der action der Route aus: Begrenze nach der Client-IP, verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit dem Honeypot weiter. Halte die Helfer in einem .server.ts-Modul, damit sie nie den Browser erreichen, und setze die CSP aus dem headers-Export der Route. Auf Cloudflare Workers kommen das Rate-Limiting-Binding und die Secrets aus env in cloudflare:workers.

Was in React Router anders ist

Im Framework-Modus nimmt jede Route mit einer action POSTs an, von deinem Formular oder von jedem anderen. React Router gibt dir drei Werkzeuge, die bestimmen, wie Spamprüfungen hineinpassen:

  • .server-Module. Eine Datei namens spam.server.ts kann nicht in Client-Code gebündelt werden, Secrets und siteverify-Aufrufe bleiben also auf dem Server, selbst wenn eine Komponente aus derselben Route importiert.
  • Der headers-Export. Header, die eine Action zurückgibt, werden bei einer Dokument-Antwort nicht gesendet, außer die headers-Funktion der Route reicht sie weiter. Auch die CSP liegt dort.
  • useFetcher. Es sendet an die Action ohne Navigation, Seite und Widget bleiben also zwischen den Versuchen gemountet. Deshalb wird das Widget nach jeder Antwort mit einem key neu gemountet.

Diese Seite deployt auf Cloudflare Workers, ein verbreitetes Zuhause für React Router, wo ein Rate-Limiting-Binding eingebaut ist. Die Deployment-Tabelle behandelt Vercel und Netlify.

Wie ein Post die React-Router-Action erreicht, mit und ohne JavaScriptHydriert: useFetcherVor der Hydration: ein Dokument-POSTfetcher.FormkeineNavigationActionLimit,siteverify,weiterleitenfetcher.dataFehler oderDankeneuesWidgetkey ändertsicheinfacherFormular-Postkein WidgetgerendertActionsiteverifylehnt abheaders-ExportCSP,Retry-AfteractionDataFehlerangezeigtPOSTdata()neu mountenPOSTrendernWie ein Post die React-Router-Action erreicht, mit und ohne JavaScriptHydriert: useFetcherVor der Hydration: ein Dokument-POSTfetcher.Formkeine NavigationActionLimit, siteverify, weiterleitenfetcher.dataFehler oder Dankeneues Widgetkey ändert sicheinfacher Formular-Postkein Widget gerendertActionsiteverify lehnt abheaders-ExportCSP, Retry-AfteractionDataFehler angezeigtPOSTdata()neu mountenPOSTrendern
  1. Nach der Hydration sendet das Formular über useFetcher, die Seite navigiert also nicht.
  2. Die Action begrenzt nach IP, verifiziert das Turnstile-Token und leitet die Felder weiter.
  3. Ihr Ergebnis kommt als fetcher.data an.
  4. Der key des Widgets ändert sich, sodass ein frisches Widget und ein frisches Token für den nächsten Versuch bereitstehen.
  5. Vor der Hydration sendet dasselbe Formular als normaler Dokument-Request, ohne Turnstile-Token.
  6. Die Action lehnt es bei siteverify ab.
  7. Der headers-Export reicht Retry-After und die CSP an die Dokument-Antwort weiter.
  8. Die Komponente liest actionData statt fetcher.data und zeigt den Fehler.
Eine Menschlichkeitsprüfung braucht JavaScript, der Weg ohne JavaScript endet also in einem klaren Fehler statt in stillem Durchwinken.

Projekt-Setup

Starte mit dem React-Router-Template von Cloudflare mit @cloudflare/vite-plugin. In React Router 8 ist das Argument context immer ein RouterContextProvider, und ein einfaches Objekt { cloudflare: { env } } aus dem Worker-Einstiegspunkt wirft einen Fehler, daher übergibt der Einstiegspunkt nur den Request, und die Route liest Bindings mit import { env } from "cloudflare:workers". Die Dateien, die diese Seite anfasst:

Dateien
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
  • Der Site Key ist öffentlich, er steht also in vars. Der Loader reicht ihn an die Komponente weiter, der Client-Build braucht also überhaupt keine Umgebungsvariable.
  • Secrets gehören lokal in .dev.vars und in der Produktion in wrangler secret put, nie in wrangler.jsonc.
  • Führe nach dem Bearbeiten der Konfiguration wrangler types aus, damit CONTACT_LIMIT und die Secrets auf env typisiert sind.

Die reinen Server-Helfer

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;
}
  • Das Suffix .server lässt den Build scheitern, wenn Client-Code dieses Modul einbinden würde.
  • Siteverify nimmt form-encoded Bodys an. Die Hostnamen-Prüfung vergleicht mit dem Host, an den der Besucher gesendet hat, und wird in der Entwicklung übersprungen, weil Test-Tokens einen Dummy-Hostnamen tragen.
  • forward sendet die ursprünglichen FormData, jedes Feld, einschließlich des Honeypots, erreicht Ikiform also so, wie es gesendet wurde.
  • Es reicht außerdem den Origin des Besuchers weiter, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Das 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 hebt ein async-<script> in den Head und lädt es einmal, egal wie oft die Komponente rendert. Der Effect rendert das Widget explizit, was Cloudflare für Single-Page-Apps empfiehlt, und entfernt es beim Unmount.

Die Route

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>
  );
}
  • Das Limit läuft vor request.formData(), eine Flut kostet also nie ein Body-Parsing oder einen siteverify-Aufruf.
  • cf-connecting-ip setzt Cloudflare bei jedem Request an einen Worker, und der Besucher kann es nicht setzen.
  • data() setzt Status und Retry-After. Ein Fetcher liest den Body direkt; der headers-Export bringt diese Header in eine Dokument-Antwort.
  • result fällt auf actionData zurück, damit der Fehler auch angezeigt wird, wenn das Formular vor der Hydration gesendet hat.
  • Die Klasse .hp versteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der Honeypot-Anleitung.

React Router rendert für die Hydration Inline-Skripte, daher behält diese Policy 'unsafe-inline' in script-src. Um darauf zu verzichten, erzeuge in entry.server.tsx eine Nonce und übergib sie an <Scripts> und das Turnstile-Skript; laut Cloudflare reicht Turnstile eine Nonce an das weiter, was es lädt.

Lokal testen

Laut Cloudflares Dokumentation führt sein Vite-Plugin deinen Servercode während der Entwicklung in der Workers-Runtime aus, env aus cloudflare:workers, .dev.vars und das Rate-Limiting-Binding verhalten sich also wie in der Produktion. Lokal setzt keine Cloudflare-Edge cf-connecting-ip, also zählt jeder Request als unknown und teilt sich ein Limit. Das ist zu erwarten. Um den echten Header zu sehen, deploye eine Preview mit wrangler versions upload und teste aus zwei Netzen.

Anderswo deployen

HostClient-IPLimiterSecrets
Cloudflare Workerscf-connecting-ipRate-Limiting-Bindingwrangler secret put
Vercelx-forwarded-for, von Vercel überschriebenRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

Cloudflare dokumentiert das Binding als ungefähr und pro Standort gezählt, was für ein Kontaktformular in Ordnung ist. Außerhalb von Workers gibt es kein Modul cloudflare:workers, lies die Secrets also aus process.env und ersetze das Binding durch einen Redis-Zähler wie den auf der Seite zu Rate-Limits.

Ikiform die Prüfungen ausführen lassen

Wenn dir Schlüssel, ein Binding und eine CSP für ein einziges Formular zu viel sind, richte ein einfaches Formular auf deinen Ikiform-Endpunkt aus und lösche die 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>

Nutze ein kleingeschriebenes <form>, nicht das <Form> von React Router, damit der Browser direkt an Ikiform sendet. Setze Spam check auf Ikiform: Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt. Wähle das Rate limit Strict, 2 alle 10 Minuten pro Besucher, und trag deine Domain unter Allowed domains ein. Abgefangener Spam bekommt dieselbe Antwort wie eine echte Nachricht und wird 30 Tage aufbewahrt. Die Anleitung zur Server-Einrichtung behandelt, wie du stattdessen deine eigene Action behältst.

Prüfen, ob es funktioniert

  1. Führe npm run dev aus und sende mit den Testschlüsseln eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50 localhost-Tests pro Monat an.
  2. Sende eine zweite Nachricht, ohne neu zu laden. Nach der ersten Antwort erscheint ein frisches Widget, und der zweite Post gelingt.
  3. Deaktiviere JavaScript und sende ab. Die Seite lädt mit dem Fehler der Menschlichkeitsprüfung neu.
  4. Sende nach dem Deployment sechsmal in einer Minute. Der sechste Post bekommt 429 mit Retry-After: 60.
  5. Sende einen Post mit ausgefülltem Honeypot, _gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.

Stolperfallen

ProblemWas zu tun ist
Server-only module referenced by clientEine Komponente importiert aus spam.server.ts etwas, das sie rendert. Nur loader und action dürfen es verwenden.
Der zweite Versuch scheitert immer an siteverifyDas Widget hat sein benutztes Token behalten. Stell sicher, dass sich sein key nach jeder Fetcher-Antwort ändert.
Invalid context value provided to handleRequestDer Worker-Einstiegspunkt übergibt noch { cloudflare: { env, ctx } }, wie es die Templates für React Router 7 taten. Ruf den Handler nur mit dem Request auf und lies Bindings aus cloudflare:workers.
Retry-After fehlt in der Antwort ohne JavaScriptAction-Header werden verworfen, außer der headers-Export gibt sie zurück. Kopiere sie wie gezeigt aus actionHeaders.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.