Offene Registrierung gegen Bot-Massenanlage absichern

Auf der Testumgebung liefen seit Juli 2026 taeglich 10-20 automatisierte
Registrierungen, die zufaellige Namen mit fremden echten E-Mail-Adressen
kombinierten - der Server verschickte Vertrags- und Verifikationsmail an
Unbeteiligte. Das IP-Rate-Limit griff nicht, weil jede Anfrage ueber eine
eigene Rechenzentrums-IP kam.

app/spam-guard.php ergaenzt daher Honigtopf, Zeitfalle, eine globale
Notbremse ueber alle IPs hinweg und eine MX/A-Pruefung der Mail-Domain.
Die ersten drei antworten mit derselben generischen Meldung wie das
Rate-Limit, damit die Antwort nicht verraet, welche Huerde angeschlagen
hat; Treffer landen im Error-Log. Bewusst ohne Captcha, um keinen
Drittanbieter in den Registrierungspfad zu holen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 23:09:38 +02:00
co-authored by Claude Opus 5
parent 3346b51b21
commit 86334c2752
4 changed files with 316 additions and 2 deletions
+27 -2
View File
@@ -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') {
</div>
<?php endif; ?>
<?php app_spam_guard_mark_form_rendered('register'); ?>
<form method="post" action="register.php">
<?php echo app_csrf_field(); ?>
<?php echo app_spam_guard_honeypot_field(); ?>
<div class="row">
<div class="col-12">
<fieldset>