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:
2026-06-20 20:41:50 +02:00
parent 73583a896c
commit e7801ca6ee
8 changed files with 140 additions and 9 deletions

View File

@@ -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()) ?>">

View File

@@ -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()) ?>">