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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,8 +7,10 @@
|
|||||||
# Laufzeit-Daten
|
# Laufzeit-Daten
|
||||||
/storage/logs/*
|
/storage/logs/*
|
||||||
/storage/cache/*
|
/storage/cache/*
|
||||||
|
/storage/ratelimit/*
|
||||||
!/storage/logs/.gitkeep
|
!/storage/logs/.gitkeep
|
||||||
!/storage/cache/.gitkeep
|
!/storage/cache/.gitkeep
|
||||||
|
!/storage/ratelimit/.gitkeep
|
||||||
|
|
||||||
# Maschinenverwaltet (bin/instagram-sync.php)
|
# Maschinenverwaltet (bin/instagram-sync.php)
|
||||||
/data/instagram.json
|
/data/instagram.json
|
||||||
|
|||||||
@@ -23,10 +23,24 @@ $respond = static function (bool $ok, string $error = '') use ($wantsJson): neve
|
|||||||
|
|
||||||
$field = static fn (string $key): string => trim((string) ($_POST[$key] ?? ''));
|
$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) ---
|
// --- 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);
|
$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 ---
|
// --- Validierung ---
|
||||||
$name = $field('name');
|
$name = $field('name');
|
||||||
@@ -51,6 +65,22 @@ if (!$valid) {
|
|||||||
$respond(false, 'validation');
|
$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 ---
|
// --- Versand ---
|
||||||
$smtp = config('smtp');
|
$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] ?? ''));
|
$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) ---
|
// --- 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);
|
$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 ---
|
// --- Eingaben ---
|
||||||
$firstName = $field('first_name');
|
$firstName = $field('first_name');
|
||||||
@@ -67,6 +81,18 @@ if (!$valid) {
|
|||||||
$respond(false, 'validation');
|
$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 ---
|
// --- Versand ---
|
||||||
$smtp = config('smtp');
|
$smtp = config('smtp');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ declare(strict_types=1);
|
|||||||
* $intro array{title, text}
|
* $intro array{title, text}
|
||||||
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
||||||
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=...) — PRG-Muster.
|
* 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');
|
$club = json_load('club');
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ $sent = isset($_GET['sent']);
|
|||||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
||||||
$errorMessages = [
|
$errorMessages = [
|
||||||
'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
|
'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'] . '.',
|
'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 */ ?>
|
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
||||||
<div class="visually-hidden" aria-hidden="true">
|
<div class="visually-hidden" aria-hidden="true">
|
||||||
<label for="contact-website">Website (bitte leer lassen)</label>
|
<label for="contact-company-url">Bitte dieses Feld leer lassen</label>
|
||||||
<input type="text" id="contact-website" name="website" tabindex="-1" autocomplete="off">
|
<input type="text" id="contact-company-url" name="company_url" tabindex="-1" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
<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)
|
* $fee_options array Beitragsklassen als Strings (Whitelist – muss zur Action passen)
|
||||||
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
* Felder rendern ausschließlich über component('form-field') — siehe CLAUDE.md.
|
||||||
* Liest Flash-Status aus Query-Params (?sent=1 / ?error=…) — PRG-Muster.
|
* 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');
|
$club = json_load('club');
|
||||||
$fee_options = $fee_options ?? [];
|
$fee_options = $fee_options ?? [];
|
||||||
@@ -17,6 +17,7 @@ $sent = isset($_GET['sent']);
|
|||||||
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
$error = isset($_GET['error']) ? (string) $_GET['error'] : null;
|
||||||
$errorMessages = [
|
$errorMessages = [
|
||||||
'validation' => 'Bitte prüfe deine Eingaben – Pflichtfelder fehlen oder die E-Mail-Adresse ist ungültig.',
|
'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'] . '.',
|
'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 */ ?>
|
<?php /* Honeypot — für Menschen unsichtbar, Bots füllen es aus */ ?>
|
||||||
<div class="visually-hidden" aria-hidden="true">
|
<div class="visually-hidden" aria-hidden="true">
|
||||||
<label for="mm-website">Website (bitte leer lassen)</label>
|
<label for="mm-company-url">Bitte dieses Feld leer lassen</label>
|
||||||
<input type="text" id="mm-website" name="website" tabindex="-1" autocomplete="off">
|
<input type="text" id="mm-company-url" name="company_url" tabindex="-1" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="ft" value="<?= e(form_token()) ?>">
|
<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.
|
* 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);
|
$parts = explode('.', $token);
|
||||||
if (count($parts) !== 2) {
|
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;
|
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).
|
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
var MESSAGES = {
|
var MESSAGES = {
|
||||||
success: 'Danke für deine Anfrage! Wir melden uns so schnell wie möglich bei dir.',
|
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.',
|
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.'
|
mail: 'Deine Nachricht konnte gerade nicht versendet werden. Bitte versuche es später erneut.'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
0
storage/ratelimit/.gitkeep
Normal file
0
storage/ratelimit/.gitkeep
Normal file
Reference in New Issue
Block a user