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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 19:07:12 +02:00
1 parent 6135c1f9c0
commit 33580b3290
14 files changed
+118 -4

No files matched your search

+13
View File
@@ -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<void> {
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<void> {
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) {
+13
View File
@@ -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<vo
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 = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
if (!parsed.success) {
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
@@ -53,6 +60,12 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
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"),
+3
View File
@@ -48,6 +48,9 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
<div className="flex items-center justify-between gap-4">
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<Button type="submit">{t.account.login}</Button>
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
{t.account.forgotPassword}
@@ -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"
/>
</label>
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
</form>
</>
@@ -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 ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
</form>
)}
+4
View File
@@ -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"
/>
</label>
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
) : null}
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
</form>
</Container>