feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
43420f0422
commit
beae7e2b13
40 changed files with 1992 additions and 40 deletions
151
app/Services/Spam/TurnstileVerifier.php
Normal file
151
app/Services/Spam/TurnstileVerifier.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
use App\Enums\BotCheckStatus;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Serverseitige Prüfung des Cloudflare-Turnstile-Tokens
|
||||
* (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, B1).
|
||||
*
|
||||
* Entscheidung 09.10.2026: Ist Cloudflare nicht erreichbar, wird nicht
|
||||
* blockiert, sondern durchgelassen und markiert (BotCheckStatus::Unavailable).
|
||||
* Das gilt für drei Fälle:
|
||||
* - Siteverify antwortet nicht (Timeout, Netzfehler, 5xx, internal-error),
|
||||
* - unsere Keys werden abgelehnt (Konfigurationsfehler, nicht Schuld des Nutzers),
|
||||
* - das Widget konnte im Browser nicht laden (Feld `turnstile_unavailable`).
|
||||
* Der letzte Fall ist vom Absender behauptet — darum bleibt er sichtbar
|
||||
* markiert und zählt in der Betriebsüberwachung. Wird er missbraucht,
|
||||
* schaltet TURNSTILE_FAIL_OPEN=false auf Ablehnen um.
|
||||
*
|
||||
* Die Hostname-Bindung prüft Cloudflare selbst (Widget-Einstellungen);
|
||||
* hier zählt zusätzlich die Aktion, damit ein Token nur für das Formular
|
||||
* gilt, für das es gelöst wurde.
|
||||
*/
|
||||
class TurnstileVerifier
|
||||
{
|
||||
public const ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/** Name des Token-Felds, das das Widget im Formular anlegt. */
|
||||
public const TOKEN_FIELD = 'cf-turnstile-response';
|
||||
|
||||
/** Vom Widget gesetzt, wenn das Skript im Browser nicht lud. */
|
||||
public const UNAVAILABLE_FIELD = 'turnstile_unavailable';
|
||||
|
||||
private const MAX_TOKEN_LENGTH = 2048;
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return filled(config('services.turnstile.site_key')) && filled(config('services.turnstile.secret_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein klassisches Formular (Token und Ausfall-Flag im Request).
|
||||
*/
|
||||
public function verifyRequest(Request $request, string $action): BotCheck
|
||||
{
|
||||
return $this->verify(
|
||||
$request->string(self::TOKEN_FIELD)->toString(),
|
||||
$request->boolean(self::UNAVAILABLE_FIELD),
|
||||
$action,
|
||||
$request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(?string $token, bool $clientUnavailable, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return new BotCheck(BotCheckStatus::Skipped);
|
||||
}
|
||||
|
||||
$token = trim((string) $token);
|
||||
|
||||
if ($token === '') {
|
||||
return $clientUnavailable
|
||||
? $this->unavailable('widget_not_loaded', $action, $ip)
|
||||
: $this->failed('missing_token', $action, $ip);
|
||||
}
|
||||
|
||||
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
|
||||
return $this->failed('token_too_long', $action, $ip);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('services.turnstile.timeout', 3))
|
||||
->post(self::ENDPOINT, array_filter([
|
||||
'secret' => config('services.turnstile.secret_key'),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]));
|
||||
} catch (ConnectionException) {
|
||||
return $this->unavailable('siteverify_unreachable', $action, $ip);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return $this->unavailable('siteverify_http_'.$response->status(), $action, $ip);
|
||||
}
|
||||
|
||||
$errors = (array) $response->json('error-codes', []);
|
||||
|
||||
if ($response->json('success') !== true) {
|
||||
if (in_array('internal-error', $errors, true)) {
|
||||
return $this->unavailable('siteverify_internal_error', $action, $ip);
|
||||
}
|
||||
|
||||
if (array_intersect(['missing-input-secret', 'invalid-input-secret'], $errors) !== []) {
|
||||
// Unser Fehler, nicht der des Absenders — laut melden, nicht blockieren.
|
||||
Log::error('Turnstile lehnt den Secret-Key ab — TURNSTILE_SECRET_KEY prüfen.', ['errors' => $errors]);
|
||||
|
||||
return $this->unavailable('invalid_secret', $action, $ip);
|
||||
}
|
||||
|
||||
return $this->failed(implode(',', $errors) ?: 'rejected', $action, $ip);
|
||||
}
|
||||
|
||||
$returnedAction = (string) $response->json('action', '');
|
||||
|
||||
// Die Cloudflare-Testkeys (1x…/2x…/3x…) liefern keine echte Aktion.
|
||||
if (! $this->usesTestSecret() && $returnedAction !== $action) {
|
||||
return $this->failed('action_mismatch', $action, $ip);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Passed);
|
||||
}
|
||||
|
||||
private function unavailable(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht verfügbar — durchgelassen und markiert.', $reason, $action, $ip);
|
||||
|
||||
if (! config('services.turnstile.fail_open', true)) {
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Unavailable, $reason);
|
||||
}
|
||||
|
||||
private function failed(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht bestanden.', $reason, $action, $ip);
|
||||
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
private function log(string $message, string $reason, string $action, ?string $ip): void
|
||||
{
|
||||
Log::channel('spam')->info($message, [
|
||||
'form' => $action,
|
||||
'reason' => $reason,
|
||||
'ip_prefix' => SpamLog::ipPrefix($ip),
|
||||
]);
|
||||
}
|
||||
|
||||
private function usesTestSecret(): bool
|
||||
{
|
||||
return (bool) preg_match('/^[123]x0{20,}/', (string) config('services.turnstile.secret_key'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue