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({

) : null} + {turnstileSiteKey ? ( +
+ +
+ ) : null} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index e780d5f..55c3b7c 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -29,6 +29,7 @@ export default async function CheckoutPage() { const customer = await getCurrentCustomer(); if (!customer) return + {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? ( +
+ +
+ ) : null} {notifyError ? (

{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 { fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut."); } + // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, + // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. + if (!(await isTurnstileValid(formData, ip))) { + fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); + } + const parsed = loginSchema.safeParse({ email: formData.get("email"), password: formData.get("password"), @@ -103,6 +110,12 @@ export async function verifyTwoFactorAction(formData: FormData): Promise { fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut."); } + // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, + // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. + if (!(await isTurnstileValid(formData, ip))) { + fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); + } + const code = String(formData.get("code") ?? "").trim(); const customer = await prisma.customer.findUnique({ where: { id: customerId } }); if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) { diff --git a/src/app/konto/actions/password-reset.ts b/src/app/konto/actions/password-reset.ts index d5b78a3..590b3c4 100644 --- a/src/app/konto/actions/password-reset.ts +++ b/src/app/konto/actions/password-reset.ts @@ -13,6 +13,7 @@ import { sendMail } from "@/lib/mail"; import { securityAlertEmail } from "@/lib/email-templates"; import { getLocale } from "@/i18n/locale"; import { fail } from "./_shared"; +import { isTurnstileValid } from "@/lib/turnstile"; const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5; const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000; @@ -28,6 +29,12 @@ export async function requestPasswordResetAction(formData: FormData): Promise { fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut."); } + // CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab, + // bevor sie Datenbank, Mailversand oder das Ratenlimit belasten. + if (!(await isTurnstileValid(formData, ip))) { + fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut."); + } + const parsed = resetPasswordSchema.safeParse({ token: formData.get("token"), password: formData.get("password"), diff --git a/src/app/konto/page.tsx b/src/app/konto/page.tsx index 1a0e00c..2ebe390 100644 --- a/src/app/konto/page.tsx +++ b/src/app/konto/page.tsx @@ -48,6 +48,9 @@ export default async function KontoPage(props: PageProps<"/konto">) {
+ {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? ( + + ) : null} {t.account.forgotPassword} diff --git a/src/app/konto/passwort-vergessen/page.tsx b/src/app/konto/passwort-vergessen/page.tsx index 193ee56..ff4ceba 100644 --- a/src/app/konto/passwort-vergessen/page.tsx +++ b/src/app/konto/passwort-vergessen/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Container, SectionHeading, Button } from "@/components/ui"; import { BackLink } from "@/components/back-link"; +import { TurnstileField } from "@/components/turnstile-widget"; import { requestPasswordResetAction } from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -41,6 +42,9 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest" /> + {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? ( + + ) : null} diff --git a/src/app/konto/passwort-zuruecksetzen/page.tsx b/src/app/konto/passwort-zuruecksetzen/page.tsx index aa49e18..a64286d 100644 --- a/src/app/konto/passwort-zuruecksetzen/page.tsx +++ b/src/app/konto/passwort-zuruecksetzen/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Container, SectionHeading, Button } from "@/components/ui"; import { BackLink } from "@/components/back-link"; import { PasswordField } from "@/components/password-field"; +import { TurnstileField } from "@/components/turnstile-widget"; import { resetPasswordAction } from "../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -43,6 +44,9 @@ export default async function PasswortZuruecksetzenPage( autoComplete="new-password" showStrength /> + {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? ( + + ) : null} )} diff --git a/src/app/konto/zwei-faktor-code/page.tsx b/src/app/konto/zwei-faktor-code/page.tsx index 0758255..fc8383d 100644 --- a/src/app/konto/zwei-faktor-code/page.tsx +++ b/src/app/konto/zwei-faktor-code/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { Container, SectionHeading, Button } from "@/components/ui"; import { BackLink } from "@/components/back-link"; +import { TurnstileField } from "@/components/turnstile-widget"; import { getTwoFactorChallengeCustomerId } from "@/lib/auth"; import { verifyTwoFactorAction } from "../actions"; import { getLocale } from "@/i18n/locale"; @@ -44,6 +45,9 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest" /> + {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? ( + + ) : null} diff --git a/src/lib/turnstile.ts b/src/lib/turnstile.ts index c5f0846..ca7d3d8 100644 --- a/src/lib/turnstile.ts +++ b/src/lib/turnstile.ts @@ -5,7 +5,7 @@ const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; // 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 — CAPTCHA-Pruefung auf Registrierung/Newsletter/Firmenanfrage ist deaktiviert." + "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)." ); } @@ -38,3 +38,16 @@ export async function verifyTurnstileToken(token: string | null, remoteIp?: stri 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); +}