Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen8 Min. Lesezeit

Spamschutz für TanStack-Start-Formulare mit Turnstile

Schütze ein TanStack-Start-Formular vor Spam: Function Middleware für die Client-IP, ein Token Bucket, Turnstile in einer Serverfunktion verifiziert und eine CSP.

Preet SutharGründer, Ikiform

Füge Function Middleware hinzu, die Client-IP und Origin einmal liest und jeder Serverfunktion als Context übergibt. Validiere in der Serverfunktion die Eingabe, nimm ein Token aus einem Bucket pro IP, verifiziere das Turnstile-Token mit siteverify und leite die Felder mit dem Honeypot weiter. Behalte die CSRF-Middleware, die Start installiert, und füge sie selbst wieder hinzu, wenn du src/start.ts anlegst.

Was in TanStack Start anders ist

Eine Serverfunktion ist ein Same-Origin-RPC-Endpunkt. Start schützt sie mit einer CSRF-Middleware vor anderen Sites, die Sec-Fetch-Site, Origin oder Referer prüft, und das verhindert, dass eine fremde Seite über den Browser eines Besuchers sendet. Ein Skript, das beliebige Header senden kann, stoppt sie nicht. Die Spamprüfungen gehören also weiterhin in die Funktion.

Starts eigener Baustein dafür ist Middleware. Function Middleware läuft vor Validator und Handler, kann Request-Header lesen und Werte über context an den Handler übergeben. Schreib die IP-Ermittlung einmal, und jedes Formular der Site bekommt sie.

Die Middleware-Kette vor der TanStack-Start-ServerfunktionVor deinem CodeIn der ServerfunktionPOST/_serverFnvomFormularoder einemSkriptCSRF-MiddlewaregleicherOrigin?Besucher-MiddlewareContext:ip, originValidatortrimmt,begrenztLängenTokenBucket3Versuche,alle 20 seinerzurücksiteverifyAction istsupportIkiformspeichert,bewertetHoneypotErgebnis:spätererneutjaToken übrigMenschleerDie Middleware-Kette vor der TanStack-Start-ServerfunktionVor deinem CodeIn der ServerfunktionPOST/_serverFnvom Formular odereinem SkriptCSRF-Middlewaregleicher Origin?Besucher-MiddlewareContext: ip, originValidatortrimmt, begrenztLängenToken Bucket3 Versuche, alle 20s einer zurücksiteverifyAction ist supportIkiformspeichert, bewertetHoneypotErgebnis:spätererneutjaToken übrigMenschleer
  1. Der Browser oder ein Skript sendet an die URL der Serverfunktion.
  2. Die CSRF-Middleware von Start lehnt Posts ab, bei denen sie nicht nachweisen kann, dass sie von deinem eigenen Origin kommen.
  3. Die Besucher-Middleware liest Client-IP und Origin-Header und gibt sie als Context weiter.
  4. Der Validator normalisiert die Eingabe, bevor der Handler sie sieht.
  5. Der Handler nimmt ein Token aus dem Bucket dieser IP. Ist keins mehr übrig, gibt er ein Später-erneut-versuchen-Ergebnis zurück.
  6. Er verifiziert das Turnstile-Token und leitet dann die Felder an Ikiform weiter, das sie speichert und den Honeypot bewertet.
Jede Schicht ist günstiger als die nächste. Ein abgelehnter Post erreicht nie siteverify oder Ikiform.

Projekt-Setup

Dateien
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 gibt nur Variablen mit dem Präfix VITE_ an den Browser weiter, gelesen über import.meta.env. Die Secrets haben kein Präfix, existieren also nur auf dem Server als process.env.
  • Die Testschlüssel bestehen auf jedem Host, auch auf localhost. Tausche sie pro Umgebung gegen echte aus.

Function Middleware für die Client-IP

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

Setze CLIENT_IP_HEADER auf den Header, den dein Host überschreibt: x-forwarded-for auf Vercel, x-nf-client-connection-ip auf Netlify, den Netlify-Mitarbeiter empfehlen, obwohl die Dokumentation in Functions nur context.ip beschreibt, und cf-connecting-ip auf Cloudflare. Auf deinem eigenen Node-Server hinter nginx nutze den Header, den nginx setzt, und stell sicher, dass die App nicht an nginx vorbei erreichbar ist. Die Middleware reicht außerdem den Origin des Besuchers weiter, weil das serverseitige fetch an Ikiform keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.

Ein Token Bucket pro IP

Ein festes Fenster lässt ein Skript am Ende einer Minute ein volles Kontingent senden und zu Beginn der nächsten noch eins. Ein Token Bucket füllt einen Versuch nach dem anderen auf, wer einen Tippfehler korrigiert, wartet also Sekunden, keine Minute:

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 };
}

Dieser Zähler liegt im Arbeitsspeicher, was auf einem einzelnen dauerhaft laufenden Server exakt ist, etwa node .output/server/index.mjs in einem Container. Auf Vercel oder Netlify hält jede Funktionsinstanz ihre eigene Map, das tatsächliche Limit ist also unbekannt. Verlege den Bucket dort nach Redis, wie die Seite zu Rate-Limits zeigt, und behalte dieselbe Form take(key).

Die Serverfunktion

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." };
  });
  • Der Validator läuft vor dem Handler und normalisiert nur die Eingabe. Feldfehler kommen als Ergebnis zurück, das das Formular beim richtigen Eingabefeld anzeigen kann.
  • Günstige Prüfungen zuerst: E-Mail- und Längenprüfung kosten nichts, der Bucket kostet ein Map-Lookup, siteverify einen Netzwerkaufruf.
  • action muss support sein. Ein Token, das mit deinem Site Key für ein anderes Widget erzeugt wurde, wird abgelehnt.
  • Der Honeypot _gotcha wird weitergeleitet, nicht hier geprüft, ein ausgefüllter wird also als Spam gespeichert, wo du ihn prüfen kannst.
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.

Die Route und das Widget

Das Widget wird explizit aus einem kleinen Hook gerendert. Ein Promise auf Modulebene lädt das Skript einmal, selbst wenn die Route bei Client-Navigation mehrmals gemountet wird, und das Token kommt über Turnstiles callback:

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>
  );
}
  • Der Button bleibt deaktiviert, bis Turnstile ein Token übergibt, damit niemand ein leeres absendet.
  • turnstile.reset() läuft nach jedem Versuch. Ein Token funktioniert einmal und lebt 300 Sekunden, ein neuer Versuch braucht also ein frisches.
  • Dieses Formular braucht JavaScript. Serverfunktionen können über ihre .url einfache Formular-Posts annehmen, aber das Turnstile-Widget braucht ohnehin einen Browser.

Sicherheits-Header in src/start.ts

Start hat keine eigene CSP-Option. Globale Request-Middleware in src/start.ts kann bei jeder Antwort eine setzen. Das Anlegen dieser Datei schaltet die CSRF-Middleware ab, die Start standardmäßig installiert, also füge sie zuerst wieder hinzu:

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],
}));
  • Das serverseitig gerenderte HTML von Start enthält Inline-Skripte. Ohne Nonce-Setup braucht script-src 'unsafe-inline'; es blockiert trotzdem Skripte von jedem Host, den du nicht aufgeführt hast.
  • Turnstile zeichnet sein Widget in einem iframe von challenges.cloudflare.com, das frame-src erlauben muss.
  • Der CSRF-filter beschränkt die Prüfung auf Serverfunktionen, normale Seitenaufrufe sind also nicht betroffen.

Deployment

HostCLIENT_IP_HEADERLimiter
Dein eigener Node-Server oder ContainerWas dein Proxy setztIm Arbeitsspeicher funktioniert
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipRate-Limiting-Binding von Workers

Vercel dokumentiert, dass es x-forwarded-for mit der Adresse des Besuchers überschreibt. Trag vor dem Deployment jeden Produktions- und Preview-Hostnamen im Turnstile-Widget ein.

Ikiform die Prüfungen ausführen lassen

Wenn du Turnstile-Schlüssel und einen Limiter nicht selbst betreiben willst, lass die Serverfunktion weg und sende aus dem Browser. Ersetze in onSubmit den Aufruf send durch eine Challenge und ein Submit, wobei box eine Ref auf ein leeres div ist, das das Widget ersetzt:

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

Setze Spam check des Formulars auf Ikiform, wähle das Rate limit Strict mit 2 alle 10 Minuten pro Besucher und füge deine Domain unter Allowed domains hinzu. Ikiform führt dann Honeypot, Turnstile auf ikiform.com, das Limit pro IP und die Inhaltsbewertung aus und bewahrt, was es abfängt, 30 Tage auf. Deine CSP braucht dann https://ikiform.com in frame-src und connect-src statt Cloudflare. Die Anleitung zur Server-Einrichtung behandelt Schlüssel, falls du die Serverfunktion behältst.

Prüfen, ob es funktioniert

  1. Starte den Dev-Server mit den Testschlüsseln und sende eine Anfrage. Sie erscheint unter Submissions. Ohne Tarif sind 50 localhost-Tests pro Monat kostenlos.
  2. Sende schnell vier Anfragen. Die vierte gibt die Später-erneut-versuchen-Meldung mit einer Sekundenzahl zurück.
  3. Setze TURNSTILE_SECRET_KEY auf das Secret, das immer scheitert. Jede Anfrage zeigt den Fehler der Menschlichkeitsprüfung.
  4. Wiederhole den Aufruf der Serverfunktion aus dem Netzwerk-Tab mit curl und ohne Origin. Die CSRF-Middleware lehnt ihn ab.
  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
Cross-Site-Posts erreichen Serverfunktionen, nachdem src/start.ts hinzugefügt wurdeEine eigene src/start.ts ersetzt die Standard-CSRF-Middleware. Füge createCsrfMiddleware() zu requestMiddleware hinzu, wie gezeigt.
Alle Besucher teilen sich einen BucketCLIENT_IP_HEADER nennt einen Header, den dein Host nicht setzt, also ist jede IP unknown. Nutze den Header aus der Tabelle oben.
Das Limit scheint sich auf Vercel zufällig zurückzusetzenJede Funktionsinstanz hat ihren eigenen Speicher. Verlege den Bucket nach Redis.
Das Widget wird in der Entwicklung doppelt gerendertReacts Strict Mode mountet Effects zweimal. Das Cleanup entfernt das erste Widget, in der Produktion erscheint also eins.

Fragen

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