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()));
}