Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen7 Min. Lesezeit

Spamschutz für Astro-Formulare mit Turnstile

Schütze ein Astro-Formular mit einer Action vor Spam: Honeypot, Turnstile mit astro:env-Secrets geprüft, ein Limit pro IP und die eingebaute CSP von Astro.

Preet SutharGründer, Ikiform

Setze die Prüfungen in eine Astro Action, an die das Formular ohne JavaScript sendet: Begrenze nach context.clientAddress, verifiziere das Turnstile-Token mit einem Secret aus astro:env/server und leite dann die Felder mit dem Honeypot weiter. Die Kontaktseite muss bei Bedarf gerendert werden, füge also einen Adapter und dort prerender = false hinzu. Wenn du lieber die ganze Site statisch hältst, sende das einfache Formular an Ikiform und lass es dieselben Prüfungen ausführen.

Was in Astro anders ist

Astro baut standardmäßig statisches HTML, und statisches HTML kann kein Geheimnis verwahren. Ein Turnstile-Secret, ein Ikiform-Secret-Key und ein Rate-Limit-Zähler brauchen alle Code, der pro Request läuft, die erste Entscheidung auf einer Astro-Site ist also, ob die Kontaktseite überhaupt bei Bedarf gerendert wird.

Wählen, wo die Spamprüfungen auf einer Astro-Site laufenKontaktseitebeiBedarfAdapter +prerender= falseAstroActionLimit,siteverify,weiterleitenIkiformSecretKey,speicherteinfachesHTML-FormularkeinAdapter,keinJavaScriptIkiformHoneypot,Turnstile,Limiteigene SchlüsselFormular-POSTJSONstatisch lassenPOSTWählen, wo die Spamprüfungen auf einer Astro-Site laufenKontaktseitebei BedarfAdapter + prerender =falseAstro ActionLimit, siteverify,weiterleitenIkiformSecret Key, speicherteinfachesHTML-FormularkeinAdapter,keinJavaScriptIkiformHoneypot,Turnstile,Limiteigene SchlüsselFormular-POSTJSONstatisch lassen
  1. Entscheide, wie die Kontaktseite gerendert wird.
  2. Mit eigenen Turnstile-Schlüsseln wird die Seite bei Bedarf über einen Adapter gerendert.
  3. Das Formular sendet an eine Astro Action, die nach IP begrenzt, das Token verifiziert und die Felder weiterleitet.
  4. Ikiform speichert die Nachricht und bewertet den Honeypot.
  5. Um die Site statisch zu halten, sendet das einfache Formular direkt an Ikiform, das Honeypot, seine verwaltete Turnstile-Prüfung und das Rate-Limit ausführt.
Beide Wege stoppen dieselben Bots. Der obere gibt dir dein eigenes Widget und dein eigenes Limit; der untere liefert gar keinen Servercode aus.

Astro prüft standardmäßig außerdem den Origin von Formular-Posts an On-Demand-Seiten, über security.checkOrigin. Das verhindert, dass die Seite einer anderen Site an deine sendet. Es stoppt kein Skript, das beliebige Header senden kann, die Prüfungen unten gelten also weiterhin.

Projekt-Setup

Dateien
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

Füge einen Adapter mit npx astro add vercel, netlify oder cloudflare hinzu. Deklariere jede Variable im astro:env-Schema, damit ein fehlendes Secret beim Import scheitert und ein Secret nie in Client-Code importiert werden kann:

.env
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
astro.config.mjs
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
  • access: "secret" hält einen Wert aus dem Bundle heraus. astro:env/server lässt sich nicht in einem Client-Skript laden.
  • scriptDirective.resources ersetzt Astros Standardwerte, also führe 'self' neben Cloudflare erneut auf. Astro hasht seine eigenen Skripte weiterhin.
  • Die CSP läuft nicht unter astro dev. Prüfe sie mit astro build und astro preview.

Ein Limit pro IP

In einer Action ist context.clientAddress die IP des Besuchers, wie dein Adapter sie von der Plattform meldet, du parst X-Forwarded-For also nie selbst. Es existiert nur auf On-Demand-Routen und wirft auf vorab gerenderten einen Fehler. Dieser Zähler hält einen Redis-Schlüssel pro IP und Minute, funktioniert also über Serverless-Instanzen hinweg:

src/lib/limit.ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}

Auf Cloudflare liest Astro 7 Bindings aus cloudflare:workers statt aus Astro.locals.runtime. Ein in wrangler.jsonc deklariertes Rate-Limiting-Binding von Workers ersetzt Redis ohne Netzwerkaufruf, zum Preis einer ungefähren Zählung pro Standort:

src/lib/limit.ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}

Das Token verifizieren

src/lib/turnstile.ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, 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 };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
  • Siteverify akzeptiert form-encoded Bodys, URLSearchParams braucht also keine Header.
  • Der Hostname wird mit context.url.hostname verglichen, dem Host, an den der Besucher tatsächlich gesendet hat. Test-Tokens tragen einen Dummy-Hostnamen, daher wird die Prüfung unter astro dev übersprungen.
  • Ein Token ist 300 Sekunden gültig und funktioniert einmal. Ein einfacher Formular-Post lädt die Seite neu, und Turnstile stellt jedes Mal ein frisches aus.

Die Action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
  • Zod läuft vor dem Handler, ein fehlerhafter Post kostet also keinen Redis-Aufruf. Feldfehler kommen über isInputError zurück auf die Seite.
  • Der Honeypot _gotcha ist im Schema optional und wird unverändert weitergeleitet. Ikiform bewertet einen ausgefüllten als Spam, du kannst ihn also trotzdem prüfen.
  • Das Token wird vor dem Weiterleiten entfernt. Ikiform kann mit einem Token, das du bereits verifiziert hast, nichts anfangen.
  • Der Origin des Besuchers wird mitgeschickt, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.
  • TOO_MANY_REQUESTS wird zu einem 429 und FORBIDDEN zu einem 403.
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 Seite

src/pages/contact.astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <div class="hp" aria-hidden="true">
        <label for="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
      </div>
      <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>

Das Formular funktioniert ganz ohne Client-JavaScript. action={actions.sendMessage} sendet an dieselbe Seite, mit dem Action-Namen im Query-String; Astro führt die Action aus, und Astro.getActionResult() liest das Ergebnis beim Rendern. Nur diese Seite wird bei Bedarf gerendert. Der Rest der Site bleibt statisch.

  • Neuladen nach einem fehlgeschlagenen Post fragt, ob das Formular erneut gesendet werden soll. Astros Dokumentation schlägt das POST-Redirect-GET-Muster vor, falls dich das stört, mit dem Ergebnis in einer Session.
  • Das Turnstile-Skript nutzt is:inline, Astro lässt es also in Ruhe. Es muss von Cloudflares eigener URL laden.
  • Die Action aus einem Client-Skript auf einer statischen Seite aufzurufen, funktioniert auch, aber dann musst du nach jedem Versuch selbst turnstile.reset() aufrufen.

Deployment

HostAdapterWo Secrets liegenLimit
Vercel@astrojs/vercelUmgebungsvariablen des ProjektsRedis
Netlify@astrojs/netlifyUmgebungsvariablen der SiteRedis
Cloudflare@astrojs/cloudflarewrangler secret putRate-Limiting-Binding von Workers

Füge deinen Produktions-Hostnamen vor dem Deployment zum Turnstile-Widget hinzu, und auch Preview-Hostnamen, wenn du dort testest.

Statisch lassen und Ikiform prüfen lassen

Die meisten Astro-Sites sind statisch, und einen Adapter nur für ein Kontaktformular hinzuzufügen, kostet wirklich etwas. Sende das Formular ohne Adapter direkt an deinen Ikiform-Endpunkt:

src/components/Contact.astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>

Setze Spam check des Formulars auf Ikiform. Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt, die Seite braucht also kein Skript, keine Schlüssel und keine CSP-Änderung. Wähle das Rate limit Strict, 2 alle 10 Minuten pro Besucher, und füge deine Domain unter Allowed domains hinzu. Spam bekommt dieselbe Antwort wie eine echte Nachricht und wird 30 Tage aufbewahrt, damit du einen Fehlalarm retten kannst. Die Astro-Einrichtungsanleitung zeigt den Rest.

Prüfen, ob es funktioniert

  1. Starte astro dev mit den Testschlüsseln und sende eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50 localhost-Tests pro Monat an.
  2. Lass den Namen leer, nachdem du das required-Attribut in den Devtools entfernt hast. Der Feldfehler erscheint unter dem Eingabefeld.
  3. Setze TURNSTILE_SECRET_KEY auf das Secret, das immer scheitert, und sende ab. Die Seite zeigt den Fehler der Menschlichkeitsprüfung.
  4. Sende sechsmal in einer Minute ab. Beim sechsten Mal kommt 429 mit der Limit-Meldung.
  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.
  6. Führe astro build und astro preview aus, öffne die Seite und prüfe, ob die Konsole keine CSP-Verstöße zeigt.

Stolperfallen

ProblemWas zu tun ist
clientAddress ist auf vorab gerenderten Seiten nicht verfügbarDie Seite oder die Action lief zur Build-Zeit. Füge der Kontaktseite export const prerender = false hinzu.
Das Ergebnis der Action ist immer undefinedDas Formular hat an eine vorab gerenderte Seite gesendet. Form-Actions laufen nur auf Seiten, die bei Bedarf gerendert werden.
Das Widget ist in der Produktion leer, in der Entwicklung aber in OrdnungDie CSP blockiert es. scriptDirective.resources muss https://challenges.cloudflare.com enthalten, und frame-src muss es ebenfalls erlauben.
403 ohne Meldung bei jedem Post von einer eigenen Domainsecurity.checkOrigin hat einen anderen Origin gesehen, oft weil ein Proxy den Host umschreibt. Stell sicher, dass der Host-Header, der Astro erreicht, zur Domain der Seite passt.
Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam anSie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden.

Fragen

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