Spam-Schutz: Rate-Limiting, Link-Heuristik & Spam-Logging
Mehrschichtiger Formular-Schutz ohne externe Dienste, ergänzend zu Honeypot und HMAC-Time-Trap: - rate_limit_ok(): pro IP+Route (5/10 min), Tages-Cap (100/Tag), Token-Replay - client_ip() / log_spam() (abgewiesene Versuche -> storage/logs/spam.log) - Honeypot-Feld website -> company_url umbenannt (contact-/membership-form) - Ratelimit-Hinweis in form.js - storage/ratelimit/ (gitignored bis auf .gitkeep) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGzc6GhWhmLJt1jC2q1SRZ
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
||||
<div class="visually-hidden" aria-hidden="true">
|
||||
<label for="contact-website">Website (bitte leer lassen)</label>
|
||||
<input type="text" id="contact-website" name="website" tabindex="-1" autocomplete="off">
|
||||
<label for="contact-company-url">Bitte dieses Feld leer lassen</label>
|
||||
<input type="text" id="contact-company-url" name="company_url" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
||||
<div class="visually-hidden" aria-hidden="true">
|
||||
<label for="mm-website">Website (bitte leer lassen)</label>
|
||||
<input type="text" id="mm-website" name="website" tabindex="-1" autocomplete="off">
|
||||
<label for="mm-company-url">Bitte dieses Feld leer lassen</label>
|
||||
<input type="text" id="mm-company-url" name="company_url" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
||||
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user