Files
Wabenhain/src/lib/turnstile.ts
T
Nick SzaunigandClaude Opus 5 33580b3290 feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem
CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt
offen, die am meisten hergeben:

- Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten
  bzw. des sechsstelligen Codes.
- Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein
  Weg herauszufinden, welche Adressen registriert sind.
- Checkout — automatisierte Bestellungen blockieren Lagerbestand und
  eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests
  brauchbar macht.
- Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse
  entgegen und loest spaeter Versand an sie aus.

Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte
Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der
Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld
— er schickt kein Formular ab.

Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine
Seite den Feldnamen anders geschrieben, waere das Token leer angekommen —
und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere
der Schutz dort lautlos ausgefallen.

Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein
BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung
nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen
Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein,
waehrend jedes Formular offensteht.

Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein
GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein
Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt
rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse.
Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben
ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches
Raetsel kostete nur Bedienbarkeit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 19:07:12 +02:00

54 lines
2.5 KiB
TypeScript

const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
// Nur beim ersten Modul-Laden geprueft (nicht pro Request) — macht ein in
// Produktion vergessenes TURNSTILE_SECRET_KEY im Server-Log sichtbar, statt
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
console.warn(
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — die CAPTCHA-Pruefung ist auf ALLEN oeffentlichen Formularen deaktiviert (Login, Zwei-Faktor, Passwort-Reset, Registrierung, Checkout, Newsletter, Firmenanfrage, Verfuegbarkeits-Benachrichtigung)."
);
}
/**
* Prueft ein Cloudflare-Turnstile-Token serverseitig gegen die Siteverify-
* API. Ohne konfigurierten TURNSTILE_SECRET_KEY wird die Pruefung
* uebersprungen (true) — analog zum Stripe/Google-Fallback ohne
* Zugangsdaten, damit die Formulare auch ohne CAPTCHA-Setup nutzbar bleiben.
*/
export async function verifyTurnstileToken(token: string | null, remoteIp?: string): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return true;
if (!token) return false;
try {
const response = await fetch(VERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token, ...(remoteIp ? { remoteip: remoteIp } : {}) }),
});
const data = (await response.json()) as { success: boolean };
return data.success === true;
} catch {
// Anders als bei fehlendem Secret (bewusst nicht konfiguriert) ist hier
// ein Secret vorhanden — Turnstile wurde also aktiv gewuenscht. Ein
// Netzwerkfehler bei der Pruefung selbst darf dann nicht stillschweigend
// als "bestanden" gewertet werden, sonst waere die Pruefung durch simples
// Stoeren der Verbindung zu Cloudflare umgehbar. Das Rate-Limiting bleibt
// in diesem Fall die einzige Verteidigungslinie.
return false;
}
}
/**
* Liest das Turnstile-Token aus einem Formular und prueft es.
*
* Gemeinsame Stelle fuer alle Formulare, damit der Feldname nur einmal
* festgelegt ist: haette eine Seite ihn anders geschrieben, waere das Token
* leer angekommen — und weil ein leeres Token bei nicht konfiguriertem
* Secret durchgeht, waere der Schutz an genau dieser Stelle lautlos
* ausgefallen.
*/
export async function isTurnstileValid(formData: FormData, remoteIp?: string): Promise<boolean> {
return verifyTurnstileToken(String(formData.get("turnstileToken") ?? ""), remoteIp);
}