Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen6 Min. Lesezeit

So begrenzt du Formulareinsendungen per Rate-Limit

Begrenze Formulareinsendungen pro Besucher mit einem gemeinsamen Speicher, antworte mit 429 und Retry-After und sperre keine Büros aus, die sich eine IP teilen.

Preet SutharGründer, Ikiform

Zähle Posts pro Besucher-IP in einem Speicher, den jede Serverinstanz teilt, etwa Redis, erlaube einen kleinen Burst und antworte mit 429 und einem Retry-After-Header, wenn das Kontingent aufgebraucht ist. Ein Zähler im Arbeitsspeicher funktioniert nur auf einem einzigen dauerhaft laufenden Server, nie auf Serverless-Hosts. Setze das Limit danach, wie echte Menschen das Formular nutzen, und lass Spielraum für Büros und Events, in denen sich viele eine IP teilen.

Was an Rate-Limits besonders ist

Ein Rate-Limit unterscheidet Spam nicht von echter Post. Es stoppt Masse: ein Skript, das in einer Schleife sendet, einen Bot, der dein Formular durchprobiert, oder einen Menschen, der auf den Button hämmert. Das macht es zur günstigsten Schicht, weil es läuft, bevor du den Body liest, und zur gröbsten, weil es einen Angreifer nicht von einem vollen Büro unterscheiden kann. Drei Dinge entscheiden, ob es hilft oder schadet: wo der Zähler liegt, wonach du zählst und wie schnell das Kontingent zurückkommt.

Ein Burst von Posts eines Besuchers gegen ein gleichmäßiges Limit pro BesucherPosts 1 bis3binnen 10Sekunden:angenommenPost 4429 mitRetry-Afteralle 12 sein weitererPost erlaubteine ruhigeMinutevollesKontingentzurückBurstwartenAuffüllenEin Burst von Posts eines Besuchers gegen ein gleichmäßiges Limit pro BesucherPosts 1 bis 3binnen 10 Sekunden: angenommenPost 4429 mit Retry-Afteralle 12 sein weiterer Post erlaubteine ruhige Minutevolles Kontingent zurückBurstwartenAuffüllen
  1. Ein Besucher sendet 3 Posts innerhalb von 10 Sekunden. Alle werden angenommen.
  2. Der nächste wird mit 429 und einem Retry-After-Header abgelehnt, der sagt, wie lange zu warten ist.
  3. Danach ist alle 12 Sekunden ein weiterer Post erlaubt.
  4. Nach einer vollen ruhigen Minute ist das ganze Kontingent zurück.
Das sind die Standard-Zahlen von Ikiform: 5 pro Minute, höchstens 3 alle 10 Sekunden. Ein gleichmäßiges Auffüllen bedeutet, dass niemand ein ganzes Fenster lang auf einen weiteren Post warten muss.

Wo der Zähler liegt

SpeicherFunktioniert aufAchte auf
Eine Map im ArbeitsspeicherEinem dauerhaft laufenden ServerGeht beim Neustart verloren. Jede Serverless-Instanz hat ihre eigene Map, das tatsächliche Limit ist also unbekannt
Redis, etwa UpstashAllem, einschließlich Serverless und EdgeEin Netzwerkaufruf pro Post. Wähle eine Region nahe deinem Server
Rate-Limiting-Binding von Cloudflare WorkersWorkersUngefähr und pro Cloudflare-Standort gezählt, nur mit Zeiträumen von 10 oder 60 Sekunden
Ein Reverse Proxy, etwa nginx limit_reqServern hinter deinem eigenen ProxyZählt pro Proxy. Braucht die echte Client-IP

Der Algorithmus zählt weniger als der Speicher, prägt aber, was Besucher spüren. Ein festes Fenster setzt sich zur vollen Minute zurück, sodass ein Skript am Ende einer Minute ein volles Kontingent senden kann und zu Beginn der nächsten noch eins. Ein gleitendes Fenster glättet diese Kante, indem es das vorherige Fenster gewichtet. Ein Token Bucket oder der ähnliche Algorithmus, den Ikiform nutzt, füllt einen Post nach dem anderen auf, sodass jemand nach einem Tippfehler ein paar Sekunden später erneut senden kann, ohne einem Burst die Tür zu öffnen. Für ein Kontaktformular funktioniert jeder davon, wenn die Zahlen dazu passen, wie Menschen es nutzen.

Die Beispiele unten nutzen @upstash/ratelimit von Upstash, weil es in jedem Framework und auf Serverless-Hosts gleich funktioniert. Setze UPSTASH_REDIS_REST_URL und UPSTASH_REDIS_REST_TOKEN und speichere den Helfer einmal:

lib/limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const contact = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  prefix: "contact",
});

export async function allow(ip: string | null | undefined) {
  const { success, reset } = await contact.limit(ip || "no-ip");
  return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}

reset ist eine Unix-Zeit in Millisekunden, daher rechnet der Helfer sie für Retry-After in ganze Sekunden um. Posts ohne IP teilen sich einen Bucket, damit ein fehlender Header kein Freifahrtschein wird.

In deinem Framework anwenden

Ein einfaches HTML-Formular hat keinen Server, der etwas zählen könnte. Sende es an Ikiform und wähle ein Preset am Formular; ein Besucher über dem Limit sieht eine kurze Bremsseite statt eines Fehlers.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>

Auf Cloudflare Workers ist das eingebaute Binding die günstigste Option:

wrangler.toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
src/index.ts
const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });

Cloudflare dokumentiert dieses Binding als ungefähr und an jedem Standort separat gezählt, ein verteilter Angriff kann es also überschreiten. Für ein exaktes Limit über Standorte hinweg empfiehlt Cloudflare ein Durable Object. Hinter deinem eigenen nginx zählt limit_req, bevor deine App den Post sieht:

nginx.conf
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;

server {
  location = /api/contact {
    limit_req zone=contact burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://127.0.0.1:3000;
  }
}

Wonach du zählst

  • IP und Formular zusammen. Die übliche Wahl. Ein Besucher kann dieses Formular nicht fluten, und die Nutzung eines anderen Formulars auf deiner Site verbraucht nicht dasselbe Kontingent.
  • Viele Menschen, eine IP. Büros, Schulen, Mobilfunkanbieter und Konferenz-WLAN setzen viele Menschen hinter eine Adresse. Halte das Limit pro IP für sie locker genug oder erhöhe es an Event-Tagen.
  • Die echte Client-IP. Hinter einem Proxy oder CDN liest du den Header, den dein Host setzt, und vertraust nur diesem. Einen rohen X-Forwarded-For-Wert kann der Client selbst setzen.
  • Die IP deines eigenen Servers. Wenn dein Server Posts an einen anderen Dienst weiterleitet, teilen sich dort alle Besucher die IP deines Servers. Begrenze auf deiner Seite, nach der IP des Besuchers, bevor du weiterleitest.

Die Rate-Limits von Ikiform

Ikiform zählt jeden Post exakt, pro Besucher-IP und Formular, bevor es den Body liest, mit gleichmäßigem Auffüllen statt festen Fenstern. Jedes Formular wählt unter Rate limit ein Preset. Ein Flutschutz von 600 pro Minute und IP über alle Formulare läuft vor allen anderen.

PresetPro BesucherPasst zu
Standard5 pro Minute, höchstens 3 alle 10 SekundenDen meisten Formularen
Relaxed20 pro MinuteEvents im gemeinsamen WLAN, Formularen hinter einem Login
Strict2 alle 10 Minuten, alle 5 Minuten einer mehrKontakt- und Vertriebsformularen
Custom1 bis 1.000 pro 10 Sekunden, Minute, 10 Minuten, Stunde oder TagEinem Eintrag pro Person, Kiosken

Das ganze Formular nimmt außerdem 300 pro Minute über alle Besucher an, außer du setzt eine Custom-Grenze. Über dem Limit sieht ein Browser-Post eine Bremsseite, und fetch bekommt 429 rate_limited mit Retry-After, das Ikiform über CORS freigibt, damit der Code oben es lesen kann. Die Dokumentation zu Rate-Limits erklärt das Auffüllen.

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.

Prüfen, ob es funktioniert

  1. Sende das Formular mehrmals hintereinander ab. Über dem Limit bekommst du 429 mit einem Retry-After-Header oder die Bremsseite von Ikiform.
  2. Warte die Sekunden aus Retry-After ab und sende erneut. Der Post wird angenommen.
  3. Sende aus einem zweiten Netz, etwa von deinem Handy ohne WLAN. Es hat sein eigenes Kontingent.
  4. Wenn dein Server Posts weiterleitet, sende schnell von zwei Geräten und prüfe, dass keines den Zähler des anderen teilt.

Stolperfallen

  • Abgelehnte Posts sollten kein Kontingent kosten. Zähle vor der Arbeit und lass einen abgelehnten Post das Fenster nicht weiter hinausschieben, sonst sperrt sich ein nervöser Besucher selbst aus.
  • Begrenze nicht das Laden der Seite. Zähle Einsendungen, nicht Aufrufe des Formulars, sonst verbraucht eine Person durch Neuladen ihre Sendungen.
  • 429 rate_limited Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte Retry-After ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden.
  • IPv6. Eine Person kann viele IPv6-Adressen haben. Für ein hartes Limit zähle nach dem /64-Präfix statt nach der vollständigen Adresse.

Fragen

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