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
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/rate-limit.php';
/**
* Schutz oeffentlicher Formulare gegen automatisierte Massenanlage.
*
* Hintergrund: Auf der Testumgebung liefen ab Juli 2026 taeglich 10-20
* Registrierungen eines Bots, der zufaellige Namen mit *fremden*, echten
* E-Mail-Adressen kombinierte. Der Server verschickte daraufhin
* Vertrags- und Verifikationsmail an Leute, die nie etwas bestellt hatten -
* Mail-Bombing ueber die eigene Domain, auf Kosten der Zustellbarkeit.
*
* Das IP-Rate-Limit half dagegen nicht: die Anfragen kamen ueber wechselnde
* Rechenzentrums-IPs mit je einem Versuch. Deshalb hier bewusst Merkmale,
* die nicht an der Herkunft haengen - und ohne Drittanbieter, der sonst in
* den Registrierungspfad und die Datenschutzerklaerung muesste.
*/
// Schneller als das kann niemand acht Felder und drei Checkboxen ausfuellen.
const APP_SPAM_GUARD_MIN_SECONDS = 4;
// Notbremse ueber alle Registrierungen hinweg, unabhaengig von der IP.
const APP_SPAM_GUARD_GLOBAL_MAX = 20;
const APP_SPAM_GUARD_GLOBAL_WINDOW = 3600;
/**
* Merkt sich, wann das Formular ausgeliefert wurde. Beim Rendern aufrufen,
* nicht beim Verarbeiten.
*/
function app_spam_guard_mark_form_rendered(string $formKey): void
{
app_start_session();
$_SESSION['spam_guard_rendered'][$formKey] = time();
}
/**
* Kam die Antwort unmenschlich schnell? Fehlt der Zeitstempel ganz, wurde
* das Formular in dieser Session nie ausgeliefert - das zaehlt ebenfalls als
* Treffer. (Ein echter Nutzer kommt hier nicht an: ohne funktionierende
* Session waere schon die CSRF-Pruefung fehlgeschlagen.)
*/
function app_spam_guard_submitted_too_fast(string $formKey, int $minSeconds = APP_SPAM_GUARD_MIN_SECONDS): bool
{
app_start_session();
$renderedAt = $_SESSION['spam_guard_rendered'][$formKey] ?? null;
if (!is_int($renderedAt)) {
return true;
}
return (time() - $renderedAt) < $minSeconds;
}
/**
* Honigtopf: fuer Menschen unsichtbar, fuer Formular-Bots ein Pflichtfeld.
*
* Die Positionierung steht bewusst inline und nicht in public.css - laedt das
* Stylesheet einmal nicht, waere das Feld sonst sichtbar und ein echter
* Nutzer koennte es ausfuellen. autocomplete="off" und der neutrale Name
* halten Passwortmanager davon ab, es selbsttaetig zu befuellen;
* tabindex="-1" und aria-hidden nehmen es aus Tastaturbedienung und
* Screenreader heraus.
*/
function app_spam_guard_honeypot_field(): string
{
return '<div style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">'
. '<label for="contact_reference">Dieses Feld bitte leer lassen</label>'
. '<input type="text" name="contact_reference" id="contact_reference" value="" autocomplete="off" tabindex="-1">'
. '</div>';
}
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()));
}
+36
View File
@@ -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 1020
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
+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>
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/**
* Regressionstest fuer app/spam-guard.php - die Huerden, die seit der
* Bot-Registrierungswelle auf der Testumgebung vor register.php stehen.
* Braucht keine Datenbank: geprueft werden Honigtopf, Zeitfalle,
* Domain-Aufloesung und die Gestalt des versteckten Feldes.
*/
require __DIR__ . '/../app/spam-guard.php';
function spam_guard_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
$failures = [];
$passes = 0;
// --- Honigtopf ---
$_POST = [];
spam_guard_assert('Leeres Honigtopf-Feld ist unauffaellig', !app_spam_guard_honeypot_tripped(), $failures, $passes);
$_POST['contact_reference'] = 'https://example.com';
spam_guard_assert('Ausgefuellter Honigtopf schlaegt an', app_spam_guard_honeypot_tripped(), $failures, $passes);
$_POST['contact_reference'] = ' ';
spam_guard_assert('Nur Leerzeichen zaehlen nicht als Treffer', !app_spam_guard_honeypot_tripped(), $failures, $passes);
$_POST = [];
// --- Zeitfalle ---
$_SESSION = [];
spam_guard_assert(
'Ohne ausgeliefertes Formular gilt die Antwort als zu schnell',
app_spam_guard_submitted_too_fast('register'),
$failures,
$passes
);
app_spam_guard_mark_form_rendered('register');
spam_guard_assert(
'Sofortiges Absenden nach dem Rendern schlaegt an',
app_spam_guard_submitted_too_fast('register'),
$failures,
$passes
);
// Menschliche Bearbeitungszeit simulieren, ohne den Test warten zu lassen.
$_SESSION['spam_guard_rendered']['register'] = time() - (APP_SPAM_GUARD_MIN_SECONDS + 1);
spam_guard_assert(
'Nach ausreichender Bearbeitungszeit geht die Antwort durch',
!app_spam_guard_submitted_too_fast('register'),
$failures,
$passes
);
spam_guard_assert(
'Der Zeitstempel gilt nur fuer sein eigenes Formular',
app_spam_guard_submitted_too_fast('anderes-formular'),
$failures,
$passes
);
// --- Domain-Aufloesung ---
spam_guard_assert(
'Domain mit MX-Eintrag wird akzeptiert',
app_spam_guard_email_domain_resolves('jemand@gmail.com'),
$failures,
$passes
);
spam_guard_assert(
'Erfundene Domain wird abgewiesen',
!app_spam_guard_email_domain_resolves('jemand@diese-domain-gibt-es-ganz-sicher-nicht-4711.invalid'),
$failures,
$passes
);
spam_guard_assert(
'Adresse ohne Punkt in der Domain wird abgewiesen',
!app_spam_guard_email_domain_resolves('jemand@localhost'),
$failures,
$passes
);
spam_guard_assert(
'Zeichenkette ohne @ wird abgewiesen',
!app_spam_guard_email_domain_resolves('keine-adresse'),
$failures,
$passes
);
// --- Gestalt des versteckten Feldes ---
$field = app_spam_guard_honeypot_field();
spam_guard_assert(
'Honigtopf ist aus dem Sichtbereich geschoben',
str_contains($field, 'left:-9999px'),
$failures,
$passes
);
spam_guard_assert(
'Honigtopf ist fuer Screenreader und Tastatur unsichtbar',
str_contains($field, 'aria-hidden="true"') && str_contains($field, 'tabindex="-1"'),
$failures,
$passes
);
spam_guard_assert(
'Honigtopf wird nicht von Passwortmanagern befuellt',
str_contains($field, 'autocomplete="off"'),
$failures,
$passes
);
spam_guard_assert(
'Honigtopf ist kein type=hidden (das ueberspringen Bots)',
!str_contains($field, 'type="hidden"'),
$failures,
$passes
);
echo "\n";
if ($failures !== []) {
fwrite(STDERR, 'Spam-Guard-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n");
exit(1);
}
echo "Spam-Guard-Check bestanden mit {$passes} Zusicherungen.\n";