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:
@@ -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