From 33580b3290089460dc0db7f76a39f05a669fcc99 Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Thu, 17 Sep 2026 19:07:12 +0200
Subject: [PATCH] feat(security): Turnstile schuetzt jetzt alle oeffentlichen
Eingabeformulare
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 15 ++++++++++++++-
scripts/preflight.ts | 4 ++--
src/app/api/checkout/route.ts | 18 ++++++++++++++++++
src/app/checkout/checkout-form.tsx | 13 +++++++++++++
src/app/checkout/page.tsx | 2 ++
src/app/honig/[slug]/page.tsx | 6 ++++++
src/app/honig/actions.ts | 8 ++++++++
src/app/konto/actions/login.ts | 13 +++++++++++++
src/app/konto/actions/password-reset.ts | 13 +++++++++++++
src/app/konto/page.tsx | 3 +++
src/app/konto/passwort-vergessen/page.tsx | 4 ++++
src/app/konto/passwort-zuruecksetzen/page.tsx | 4 ++++
src/app/konto/zwei-faktor-code/page.tsx | 4 ++++
src/lib/turnstile.ts | 15 ++++++++++++++-
14 files changed, 118 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 4757107..291be2e 100644
--- a/README.md
+++ b/README.md
@@ -183,7 +183,20 @@ Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Host
## CAPTCHA (Cloudflare Turnstile)
-Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar:
+**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
+
+| Formular | Missbrauch, der damit abgewehrt wird |
+|----------|--------------------------------------|
+| Login | Durchprobieren erbeuteter Zugangsdaten |
+| Zwei-Faktor-Code | Durchprobieren des sechsstelligen Codes |
+| Passwort vergessen / zurücksetzen | Mailversand an fremde Adressen, Abfragen welche Adressen registriert sind |
+| Registrierung | Massenhaft angelegte Konten |
+| Checkout | Blockieren von Lagerbestand, Kartentests über die Zahlungsstrecke |
+| Newsletter | Eintragen fremder Adressen |
+| Firmenanfrage | Spam über das Kontaktformular |
+| Verfügbarkeits-Benachrichtigung | Mailversand an fremde Adressen |
+
+Nicht geschützt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile wäre nach dem ersten Aufruf verbraucht. Sie ist stattdessen rate-limitiert und verlangt Bestellnummer **und** zugehörige E-Mail-Adresse.
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
diff --git a/scripts/preflight.ts b/scripts/preflight.ts
index 358312c..bc12507 100644
--- a/scripts/preflight.ts
+++ b/scripts/preflight.ts
@@ -149,10 +149,10 @@ must(
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
// --- Missbrauchsschutz -----------------------------------------------------
-should(
+must(
"Cloudflare Turnstile konfiguriert",
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
- "Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
+ "Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts
index a6fcc55..0bbe096 100644
--- a/src/app/api/checkout/route.ts
+++ b/src/app/api/checkout/route.ts
@@ -8,6 +8,7 @@ import { getProductsByIds } from "@/lib/data";
import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
+import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
@@ -55,6 +56,23 @@ export async function POST(request: Request) {
}
const body = await request.json().catch(() => null);
+
+ // CAPTCHA vor allem Weiteren: eine Bestellung legt Datensaetze an,
+ // reserviert Lagerbestand und eroeffnet eine Stripe-Sitzung. Automatisiert
+ // abgeschickt liesse sich damit der Bestand blockieren und die
+ // Zahlungsstrecke fuer Kartentests missbrauchen. Das Token kommt hier aus
+ // dem JSON-Body, weil der Checkout kein Formular abschickt.
+ const turnstileToken =
+ body && typeof body === "object" && "turnstileToken" in body
+ ? String((body as { turnstileToken?: unknown }).turnstileToken ?? "")
+ : "";
+ if (!(await verifyTurnstileToken(turnstileToken, ip))) {
+ return NextResponse.json(
+ { error: "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte lade die Seite neu und versuche es erneut." },
+ { status: 400 }
+ );
+ }
+
const parsed = checkoutSchema.safeParse(body);
if (!parsed.success) {
diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx
index 7e8814a..e48b134 100644
--- a/src/app/checkout/checkout-form.tsx
+++ b/src/app/checkout/checkout-form.tsx
@@ -10,6 +10,7 @@ import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
+import { TurnstileWidget } from "@/components/turnstile-widget";
type Fulfillment = "VERSAND" | "ABHOLUNG";
@@ -41,6 +42,7 @@ export function CheckoutForm({
tiers,
bulkDiscountTiers,
packstationSearchAvailable = false,
+ turnstileSiteKey,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
@@ -51,6 +53,8 @@ export function CheckoutForm({
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
*/
packstationSearchAvailable?: boolean;
+ /** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
+ turnstileSiteKey?: string;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
@@ -59,6 +63,9 @@ export function CheckoutForm({
const totalQuantity = cartItemCount(items);
const router = useRouter();
+ // Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
+ // Token-Rueckgabe statt eines versteckten Feldes.
+ const [turnstileToken, setTurnstileToken] = useState(null);
const [fulfillment, setFulfillment] = useState("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
@@ -160,6 +167,7 @@ export function CheckoutForm({
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
+ turnstileToken: turnstileToken ?? undefined,
};
try {
@@ -456,6 +464,11 @@ export function CheckoutForm({