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
{notifyError}
diff --git a/src/app/honig/actions.ts b/src/app/honig/actions.ts index 7601c75..21ef4c3 100644 --- a/src/app/honig/actions.ts +++ b/src/app/honig/actions.ts @@ -8,6 +8,7 @@ import { hasCustomerPurchased, getCustomerReview } from "@/lib/reviews"; import { isProductWishlisted } from "@/lib/wishlist"; import { reviewSchema, stockNotificationSchema } from "@/lib/validation"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { isTurnstileValid } from "@/lib/turnstile"; import { getLocale } from "@/i18n/locale"; const REVIEW_ATTEMPTS_PER_WINDOW = 10; @@ -119,6 +120,13 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut."); } + // Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter + // Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber + // unseren Absender anzuschreiben. + if (!(await isTurnstileValid(formData, ip))) { + failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); + } + const parsed = stockNotificationSchema.safeParse({ productId: formData.get("productId"), email: formData.get("email"), diff --git a/src/app/konto/actions/login.ts b/src/app/konto/actions/login.ts index 395677f..d08636b 100644 --- a/src/app/konto/actions/login.ts +++ b/src/app/konto/actions/login.ts @@ -9,6 +9,7 @@ import { loginSchema } from "@/lib/validation"; import { rateLimit, getClientIp } from "@/lib/rate-limit"; import type { Customer } from "@/generated/prisma/client"; import { fail } from "./_shared"; +import { isTurnstileValid } from "@/lib/turnstile"; const LOGIN_ATTEMPTS_PER_WINDOW = 10; const LOGIN_WINDOW_MS = 15 * 60 * 1000; @@ -39,6 +40,12 @@ export async function loginAction(formData: FormData): Promise