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 { 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 { return verifyTurnstileToken(String(formData.get("turnstileToken") ?? ""), remoteIp); }