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:
@@ -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()));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+27
-2
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user