diff --git a/app/spam-guard.php b/app/spam-guard.php new file mode 100644 index 0000000..38609f8 --- /dev/null +++ b/app/spam-guard.php @@ -0,0 +1,118 @@ +' + . '' + . '' + . ''; +} + +function app_spam_guard_honeypot_tripped(): bool +{ + return trim((string)($_POST['contact_reference'] ?? '')) !== ''; +} + +/** + * Nimmt die Domain der Adresse ueberhaupt Mail an? Ohne MX-Eintrag gilt nach + * RFC 5321 der A/AAAA-Record als Ziel, deshalb beide als Rueckfallebene. + * + * Bewusst fail-open: steht checkdnsrr() nicht zur Verfuegung, wird die + * Adresse durchgelassen. Eine kaputte DNS-Aufloesung darf keine echten + * Registrierungen blockieren. + */ +function app_spam_guard_email_domain_resolves(string $email): bool +{ + if (!function_exists('checkdnsrr')) { + return true; + } + + $at = strrpos($email, '@'); + if ($at === false) { + return false; + } + + $domain = rtrim(substr($email, $at + 1), '.'); + if ($domain === '' || !str_contains($domain, '.')) { + return false; + } + + return @checkdnsrr($domain, 'MX') + || @checkdnsrr($domain, 'A') + || @checkdnsrr($domain, 'AAAA'); +} + +/** + * Ein Treffer wird protokolliert, aber dem Absender gegenueber nicht + * benannt: er bekommt dieselbe generische Meldung wie bei einem + * ausgereizten Rate-Limit. So verraet die Antwort nicht, welche Huerde + * angeschlagen hat, und ein Bot kann sich nicht darauf einstellen. + */ +function app_spam_guard_log(string $formKey, string $reason): void +{ + error_log(sprintf('Spam-Guard: %s abgewiesen (%s), IP %s', $formKey, $reason, app_client_ip())); +} diff --git a/docs/deployment.md b/docs/deployment.md index 1763820..2dac712 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -266,6 +266,42 @@ Stripe-Flow zur Aktualisierung der Zahlungsart. Im Stripe-Dashboard dürfen der zusätzlich freigeschaltet werden; Bestellungen und Kündigungen sollen nur über die dokumentierten Abläufe der Kaffeeliste erfolgen. +## Schutz der offenen Registrierung + +Ab Juli 2026 liefen auf `testumgebung.kaffeeliste.de` täglich 10–20 +automatisierte Registrierungen: zufällige Namen kombiniert mit **fremden, +echten** E-Mail-Adressen. Der Server verschickte daraufhin Vertrags- und +Verifikationsmail an Leute, die nie etwas bestellt hatten — Mail-Bombing +über die eigene Domain, auf Kosten der Zustellbarkeit. Das IP-Rate-Limit +griff nicht, weil jede Anfrage über eine eigene Rechenzentrums-IP kam. + +`app/spam-guard.php` stellt deshalb vor `register.php`: + +- **Honigtopf** — ein für Menschen unsichtbares Feld (`contact_reference`), + das Formular-Bots ausfüllen. Die Positionierung steht bewusst inline und + nicht in `public.css`: lädt das Stylesheet einmal nicht, wäre das Feld + sonst sichtbar und ein echter Nutzer könnte hineinschreiben. +- **Zeitfalle** — mindestens vier Sekunden zwischen Ausliefern und + Absenden des Formulars. +- **Globale Notbremse** — höchstens 20 Registrierungen pro Stunde über + alle IPs hinweg, zusätzlich zum bestehenden Limit von 5 pro IP. +- **Domain-Prüfung** — die Mail-Domain muss einen MX- oder A/AAAA-Record + haben. Fehlt `checkdnsrr()`, wird durchgelassen: eine kaputte + DNS-Auflösung darf keine echten Registrierungen blockieren. + +Honigtopf, Zeitfalle und beide Rate-Limits antworten mit **derselben** +generischen Meldung, damit die Antwort nicht verrät, welche Hürde +angeschlagen hat. Treffer stehen im PHP-Error-Log (`Spam-Guard: …`) — dort +lässt sich ablesen, ob die Maßnahmen greifen. Nur die Domain-Prüfung nennt +den Grund, weil das in aller Regel ein Tippfehler eines echten Nutzers ist. + +Bewusst **kein** Captcha: Turnstile oder hCaptcha wären wirksam, holen aber +einen Drittanbieter in den Registrierungspfad und damit in die +Datenschutzerklärung. Erst wenn die obigen Hürden nachweislich nicht +reichen, ist das die nächste Stufe. + +Abgedeckt von `scripts/check-spam-guard.php` (ohne Datenbank lauffähig). + ## Betreiber-Benachrichtigung bei neuer Registrierung `register.php` schickt nach einer erfolgreichen Selbstregistrierung einen diff --git a/register.php b/register.php index 23906d8..6ed8c68 100644 --- a/register.php +++ b/register.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/app/saas-mail.php'; require_once __DIR__ . '/app/rate-limit.php'; require_once __DIR__ . '/app/legal.php'; require_once __DIR__ . '/app/pushover.php'; +require_once __DIR__ . '/app/spam-guard.php'; $pdo = app_db_pdo(); $errors = []; @@ -19,8 +20,30 @@ $values = [ if ($_SERVER['REQUEST_METHOD'] === 'POST') { app_require_csrf(); - if (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) { - $errors = ['Zu viele Registrierungsversuche. Bitte versuche es später erneut.']; + // Reihenfolge mit Absicht: erst die billigen Bot-Merkmale, dann die + // Rate-Limits, erst danach die DNS-Abfrage. Alle Abweisungen bis + // einschliesslich Rate-Limit nennen denselben generischen Text, damit die + // Antwort nicht verraet, welche Huerde angeschlagen hat. + $genericRejection = 'Zu viele Registrierungsversuche. Bitte versuche es später erneut.'; + + if (app_spam_guard_honeypot_tripped()) { + app_spam_guard_log('register', 'honeypot'); + $errors = [$genericRejection]; + } elseif (app_spam_guard_submitted_too_fast('register')) { + app_spam_guard_log('register', 'zu schnell abgesendet'); + $errors = [$genericRejection]; + } elseif (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) { + $errors = [$genericRejection]; + } elseif (!app_rate_limit_check($pdo, 'register_global', APP_SPAM_GUARD_GLOBAL_MAX, APP_SPAM_GUARD_GLOBAL_WINDOW)) { + // Notbremse gegen verteilte Bots: die kommen je ueber eine eigene IP, + // an der das IP-Limit nie anschlaegt. + app_spam_guard_log('register', 'globales Limit'); + $errors = [$genericRejection]; + } elseif (filter_var($values['email'], FILTER_VALIDATE_EMAIL) !== false + && !app_spam_guard_email_domain_resolves($values['email'])) { + // Hier bewusst konkret: das ist typischerweise ein Tippfehler in der + // Domain, und der Hinweis hilft einem echten Nutzer weiter. + $errors = ['Zu dieser E-Mail-Adresse gibt es keine erreichbare Mail-Domain. Bitte prüfe die Schreibweise.']; } else { $result = saas_register_tenant_owner($pdo, [ 'tenant_name' => $values['tenant_name'], @@ -113,8 +136,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { +
+
diff --git a/scripts/check-spam-guard.php b/scripts/check-spam-guard.php new file mode 100644 index 0000000..37c14c6 --- /dev/null +++ b/scripts/check-spam-guard.php @@ -0,0 +1,135 @@ +