diff --git a/.gitignore b/.gitignore index e838f6f..7c563b0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ # Laufzeit-Daten /storage/logs/* /storage/cache/* +/storage/ratelimit/* !/storage/logs/.gitkeep !/storage/cache/.gitkeep +!/storage/ratelimit/.gitkeep # Maschinenverwaltet (bin/instagram-sync.php) /data/instagram.json diff --git a/app/actions/contact-submit.php b/app/actions/contact-submit.php index 4fc06cb..0bfa98c 100644 --- a/app/actions/contact-submit.php +++ b/app/actions/contact-submit.php @@ -23,10 +23,24 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson): neve $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? '')); +$route = 'kontakt'; +$token = $field('ft'); + // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) --- -if ($field('website') !== '' || !form_token_valid($field('ft'))) { +if ($field('company_url') !== '') { + log_spam($route, 'honeypot'); $respond(true); } +if (!form_token_valid($token)) { + log_spam($route, 'token'); + $respond(true); +} + +// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen). +if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) { + log_spam($route, 'ratelimit'); + $respond(false, 'ratelimit'); +} // --- Validierung --- $name = $field('name'); @@ -51,6 +65,22 @@ if (!$valid) { $respond(false, 'validation'); } +// --- Inhalts-/Aufkommens-Heuristiken (still abweisen, Bot-typisch) --- +if (preg_match_all('~https?://~i', $subject . ' ' . $message) > 2) { + log_spam($route, 'links'); + $respond(true); +} +// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken. +if (!rate_limit_ok('daily', 100, 86400)) { + log_spam($route, 'daily-cap'); + $respond(true); +} +// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden). +if (!rate_limit_ok('token|' . $token, 3, 86400)) { + log_spam($route, 'replay'); + $respond(true); +} + // --- Versand --- $smtp = config('smtp'); diff --git a/app/actions/membership-submit.php b/app/actions/membership-submit.php index 5764394..b691f12 100644 --- a/app/actions/membership-submit.php +++ b/app/actions/membership-submit.php @@ -26,10 +26,24 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson): neve $field = static fn (string $key): string => trim((string) ($_POST[$key] ?? '')); +$route = 'mitglied-werden'; +$token = $field('ft'); + // --- Spam-Checks: Bots bekommen ein stilles "OK" (kein Feedback-Kanal) --- -if ($field('website') !== '' || !form_token_valid($field('ft'))) { +if ($field('company_url') !== '') { + log_spam($route, 'honeypot'); $respond(true); } +if (!form_token_valid($token)) { + log_spam($route, 'token'); + $respond(true); +} + +// Rate-Limit pro IP (sichtbarer Hinweis statt still — legitime NAT-Nutzer nicht im Dunkeln lassen). +if (!rate_limit_ok('submit|' . $route . '|' . client_ip(), 5, 600)) { + log_spam($route, 'ratelimit'); + $respond(false, 'ratelimit'); +} // --- Eingaben --- $firstName = $field('first_name'); @@ -67,6 +81,18 @@ if (!$valid) { $respond(false, 'validation'); } +// --- Aufkommens-Heuristiken (still abweisen) --- +// Globaler Tages-Cap als Brevo-Kosten-Backstop — die Flut soll ihn nicht bemerken. +if (!rate_limit_ok('daily', 100, 86400)) { + log_spam($route, 'daily-cap'); + $respond(true); +} +// Token-Mehrfachnutzung begrenzen (Replay-Schutz, großzügig für menschliches Mehrfach-Senden). +if (!rate_limit_ok('token|' . $token, 3, 86400)) { + log_spam($route, 'replay'); + $respond(true); +} + // --- Versand --- $smtp = config('smtp'); diff --git a/app/components/contact-form.php b/app/components/contact-form.php index 680ab71..55b9551 100644 --- a/app/components/contact-form.php +++ b/app/components/contact-form.php @@ -7,7 +7,7 @@ declare(strict_types=1); * $intro array{title, text} * Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md. * Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster. - * Spam-Schutz: Honeypot "website" + signierter Timestamp "ft" (form_token()). + * Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()). */ $club = json_load('club'); @@ -17,6 +17,7 @@ $sent = isset($_GET['sent']); $error = isset($_GET['error']) ? (string) $_GET['error'] : null; $errorMessages = [ 'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.', + 'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.', 'mail' => 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.', ]; @@ -68,8 +69,8 @@ $f = [ diff --git a/app/components/membership-form.php b/app/components/membership-form.php index 52d7bfb..015a9b7 100644 --- a/app/components/membership-form.php +++ b/app/components/membership-form.php @@ -8,7 +8,7 @@ declare(strict_types=1); * $fee_options array Beitragsklassen als Strings (Whitelist – muss zur Action passen) * Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md. * Liest Flash-Status aus Query-Params (?sent=1 / ?error=…) — PRG-Muster. - * Spam-Schutz: Honeypot "website" + signierter Timestamp "ft" (form_token()). + * Spam-Schutz: Honeypot "company_url" + signierter Timestamp "ft" (form_token()). */ $club = json_load('club'); $fee_options = $fee_options ?? []; @@ -17,6 +17,7 @@ $sent = isset($_GET['sent']); $error = isset($_GET['error']) ? (string) $_GET['error'] : null; $errorMessages = [ 'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.', + 'ratelimit' => 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.', 'mail' => 'Dein Antrag konnte gerade nicht versendet werden. Bitte versuche es später erneut oder schreib uns direkt an ' . $club['email'] . '.', ]; @@ -102,8 +103,8 @@ $f = [ diff --git a/app/helpers.php b/app/helpers.php index cc47f85..b4f03fc 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -129,8 +129,10 @@ function form_token(): string /** * Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max. + * Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht + * grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab. */ -function form_token_valid(string $token, int $min = 3, int $max = 7200): bool +function form_token_valid(string $token, int $min = 3, int $max = 86400): bool { $parts = explode('.', $token); if (count($parts) !== 2) { @@ -144,6 +146,74 @@ function form_token_valid(string $token, int $min = 3, int $max = 7200): bool return $age >= $min && $age <= $max; } +/** + * Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For + * ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet. + */ +function client_ip(): string +{ + return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); +} + +/** + * Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein + * APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den + * letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst + * false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt + * (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt. + */ +function rate_limit_ok(string $key, int $max, int $window): bool +{ + $dir = STORAGE_PATH . '/ratelimit'; + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return true; + } + + // Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen. + if (random_int(1, 100) === 1) { + foreach (glob($dir . '/*.json') ?: [] as $stale) { + if ((int) @filemtime($stale) < time() - 86400) { + @unlink($stale); + } + } + } + + $file = $dir . '/' . hash('sha256', $key) . '.json'; + $fh = @fopen($file, 'c+'); + if ($fh === false) { + return true; + } + try { + flock($fh, LOCK_EX); + $raw = (string) stream_get_contents($fh); + $hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : []; + $now = time(); + $hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window)); + if (count($hits) >= $max) { + return false; + } + $hits[] = $now; + rewind($fh); + ftruncate($fh, 0); + fwrite($fh, (string) json_encode($hits)); + return true; + } finally { + flock($fh, LOCK_UN); + fclose($fh); + } +} + +/** + * Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine + * Beobachtbarkeit zum Tunen der Schwellen. Datensparsam: nur ein gekürzter, + * gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay. + */ +function log_spam(string $route, string $reason): void +{ + $ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12); + error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log'); +} + /** * BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad). */ diff --git a/public/assets/js/form.js b/public/assets/js/form.js index 0b2535e..f3a0a71 100644 --- a/public/assets/js/form.js +++ b/public/assets/js/form.js @@ -15,6 +15,7 @@ var MESSAGES = { success: 'Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.', validation: 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.', + ratelimit: 'Zu viele Anfragen in kurzer Zeit. Bitte versuche es in ein paar Minuten erneut.', mail: 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut.' }; diff --git a/storage/ratelimit/.gitkeep b/storage/ratelimit/.gitkeep new file mode 100644 index 0000000..e69de29