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>
119 lines
4.2 KiB
PHP
119 lines
4.2 KiB
PHP
<?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()));
|
|
}
|