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()));
|
||||
}
|
||||
Reference in New Issue
Block a user