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:
Kevin Adametz 2026-10-09 08:49:58 +00:00
parent 61f449f483
commit 06d2889a4d
40 changed files with 1992 additions and 40 deletions

View file

@ -3,6 +3,8 @@
namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\ArchiveEntry;
use App\Models\FailedLoginTally;
use App\Models\PressReleaseAttachment;
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Monitoring\HealthProbe;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@ -251,6 +254,32 @@ class AdminSystemHealth
: 'Imagick fehlt — Herkunftsprüfung'.($precheckEnabled ? ' und KI-Vorab-Check' : '').' der Bilder laufen ins Leere. Auf dem Server php8.4-imagick installieren und PHP-FPM neu laden.',
];
// Spamschutz (Cloudflare Turnstile): Keys in Produktion gesetzt, und
// Formulare, die bei nicht erreichbarem Cloudflare durchgelassen und
// markiert wurden (Entscheidung 09.10.2026) — die gehören nachgeprüft.
$turnstileEnabled = TurnstileVerifier::enabled();
$checks[] = [
'key' => 'turnstile',
'label' => 'Spamschutz (Cloudflare Turnstile)',
'status' => ($turnstileEnabled || ! app()->isProduction()) ? self::StatusOk : self::StatusWarn,
'detail' => $turnstileEnabled
? 'Bot-Prüfung für Kontakt-Widget und Registrierung ist aktiv.'
: 'TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY fehlen — Kontakt-Widget und Registrierung laufen ohne Bot-Prüfung.',
];
$unchecked = ActivityEvent::query()
->where('type', ActivityType::BotCheckUnavailable->value)
->where('occurred_at', '>=', now()->subDays(7))
->count();
$checks[] = [
'key' => 'bot_check_unavailable',
'label' => 'Formulare ohne Bot-Prüfung',
'status' => $unchecked > 0 ? self::StatusWarn : self::StatusOk,
'detail' => $unchecked > 0
? sprintf('%d Formular(e) in 7 Tagen angenommen, während Cloudflare nicht erreichbar war — im Verlauf (Typ „Ohne Bot-Prüfung“) nachprüfen.', $unchecked)
: 'In den letzten 7 Tagen wurde jedes Formular geprüft.',
];
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben.

View file

@ -0,0 +1,27 @@
<?php
namespace App\Services\Spam;
use App\Enums\BotCheckStatus;
/**
* Ergebnis einer Turnstile-Prüfung samt technischem Grund (für Log und
* Markierung, nie für den Nutzer).
*/
final readonly class BotCheck
{
public function __construct(
public BotCheckStatus $status,
public string $reason = '',
) {}
public function blocks(): bool
{
return $this->status->blocks();
}
public function needsReview(): bool
{
return $this->status === BotCheckStatus::Unavailable;
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace App\Services\Spam;
use App\Rules\NoPromotionalText;
use App\Services\PressRelease\BlacklistService;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
/**
* Inhaltsfilter des Kontaktformulars für nicht angemeldete Absender
* (Spamschutz B2). Gemessen an 141 Anfragen aus 30 Tagen bis 07.10.2026:
* 56 mit Link, 43 mit Floskel plus Seitentitel, dazu Zeichensalat.
*
* Ein Treffer blockiert nicht, sondern legt die Anfrage in die Quarantäne
* (ContactMessageStatus::Spam) — Fehlalarme holt der Admin zurück.
*/
class ContactSpamFilter
{
/** Feld mit dem verschlüsselten Zeitpunkt, zu dem das Formular ausgeliefert wurde. */
public const STARTED_FIELD = 'form_started';
/** Schneller füllt kein Mensch Name, E-Mail und Nachricht aus. */
private const MIN_SECONDS = 3;
/** HTML-Links und BBCode, die das Link-Muster nicht abdeckt. */
private const MARKUP_PATTERN = '~<a\s|\[url[\]=]~iu';
public function __construct(private readonly BlacklistService $blacklist) {}
public static function startedToken(): string
{
return Crypt::encryptString((string) now()->getTimestamp());
}
/**
* Grund (technischer Schlüssel) oder null, wenn die Anfrage sauber ist.
*/
public function reasonFor(string $name, string $message, ?string $startedToken): ?string
{
if ($this->containsForeignLink($message)) {
return 'link';
}
if ($this->quotesPageTitle($message)) {
return 'page_title';
}
if (GibberishDetector::isRandomToken($name) || GibberishDetector::isRandomToken($message)) {
return 'gibberish';
}
if ($this->blacklist->find($name.' '.$message) !== null) {
return 'blacklist';
}
if ($this->submittedTooFast($startedToken)) {
return 'too_fast';
}
return null;
}
/**
* Gäste dürfen keine Links schicken (Entscheidung 5) — außer die Nachricht
* verweist auf eine unserer Seiten: Korrekturwünsche nennen die eigene
* Pressemitteilung und oft daneben die Firmen-Website (KT-32, KT-161).
*/
private function containsForeignLink(string $message): bool
{
if (preg_match(self::MARKUP_PATTERN, $message) === 1) {
return true;
}
if (preg_match(NoPromotionalText::LINK_PATTERN, $message) !== 1) {
return false;
}
$ownHosts = collect((array) config('domains.domains', []))
->pluck('domain_name')
->filter()
->map(fn (string $host): string => preg_quote(Str::after($host, 'www.'), '~'))
->unique()
->implode('|');
if ($ownHosts === '') {
return true;
}
// Mit Schema („https://presseecho.de“) oder mit Pfad („presseecho.de/de/…“).
$ownLink = '~https?://(?:www\.)?(?:'.$ownHosts.')\b|\b(?:www\.)?(?:'.$ownHosts.')/~i';
return preg_match($ownLink, $message) !== 1;
}
/**
* Formular-Bots hängen den Seitentitel an eine Floskel: „Ich wünsche weitere
* Informationen … — aktuelle pressemitteilungen aus der deutschen wirtschaft.“
*/
private function quotesPageTitle(string $message): bool
{
$haystack = Str::lower($message);
foreach ((array) config('domains.domains', []) as $domain) {
foreach (preg_split('/\s[–-]\s/u', (string) ($domain['brand']['meta_title'] ?? '')) ?: [] as $segment) {
$segment = Str::lower(trim($segment));
if (mb_strlen($segment) >= 20 && str_contains($haystack, $segment)) {
return true;
}
}
}
return false;
}
/**
* Fehlt der Zeitstempel oder ist er manipuliert, entscheidet Turnstile —
* dieser Filter greift nur bei nachweislich zu schnellem Absenden.
*/
private function submittedTooFast(?string $startedToken): bool
{
if (blank($startedToken)) {
return false;
}
try {
$startedAt = (int) Crypt::decryptString($startedToken);
} catch (DecryptException) {
return false;
}
return now()->getTimestamp() - $startedAt < self::MIN_SECONDS;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\Spam;
/**
* Erkennt Zufallszeichenketten, wie sie Bots in Namensfelder schreiben
* („ruLrlotwaCoVStmZetSxD“, „NAEWTRER2720160NEYRTHYT“).
*
* Beide Regeln sind am Bestand vom 07.10.2026 gemessen: keine einzige
* Person unter rund 67.000 Konten und 0 Kontakte getroffen.
*/
final class GibberishDetector
{
/**
* Personenname (Registrierung): ein einziges Wort aus mindestens 12
* Buchstaben mit mindestens drei Großbuchstaben nach dem ersten Zeichen.
* Trifft im Bestand 511 Bot-Konten und keinen echten Namen. Firmennamen
* wie „StammZellenPowerTeam“ fielen darunter — darum nur für Personen.
*/
public static function isRandomPersonName(string $name): bool
{
$name = trim($name);
return preg_match('/^[A-Za-z]{12,}$/', $name) === 1
&& preg_match_all('/[A-Z]/', substr($name, 1)) >= 3
&& preg_match_all('/[a-z]/', $name) >= 3;
}
/**
* Freies Namensfeld, das auch Firmen tragen kann (Kontaktformular):
* mindestens vier Wechsel von klein auf groß („…aCoVStmZet…“), oder ein
* Block Großbuchstaben + Ziffern + Großbuchstaben.
*/
public static function isRandomToken(string $value): bool
{
$value = trim($value);
if (preg_match('/^[A-Za-z]{12,}$/', $value) === 1 && preg_match_all('/[a-z][A-Z]/', $value) >= 4) {
return true;
}
return preg_match('/[A-Z]{5,}\d{4,}[A-Z]{5,}/', $value) === 1;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Services\Spam;
/**
* Hilfen für den Log-Kanal `spam`: nur Netz-Präfix statt voller IP, nie
* Inhalte — der Kanal zählt abgewiesene Anfragen, er archiviert sie nicht.
*/
final class SpamLog
{
public static function ipPrefix(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return implode('.', array_slice(explode('.', $ip), 0, 3)).'.x';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$expanded = explode(':', (string) inet_ntop((string) inet_pton($ip)));
return implode(':', array_slice($expanded, 0, 3)).'::/48';
}
return null;
}
}

View 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'));
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Services\Web;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage;
use Illuminate\Support\Facades\Mail;
/**
* Benachrichtigt die Brand-Kontaktadresse über ein Kontakt-Anliegen
* (config domains.domains.{portal}.brand.contact_email, Fallback:
* Absenderadresse der Anwendung). Läuft beim Eingang und beim Zurückholen
* eines Fehlalarms aus der Spam-Quarantäne.
*/
class ContactMessageNotifier
{
public function notify(ContactMessage $message, ?string $host = null): void
{
Mail::to($this->recipient($message))->queue((new ContactRequestSubmitted(
senderName: (string) $message->name,
senderEmail: (string) $message->email,
messageText: (string) $message->message,
host: $host ?? $this->hostOf($message),
topicLabel: $message->topic->label(),
pageUrl: $message->page_url,
))->afterCommit());
}
private function recipient(ContactMessage $message): string
{
return config('domains.domains.'.$message->portal.'.brand.contact_email')
?? config('mail.from.address');
}
private function hostOf(ContactMessage $message): string
{
return parse_url((string) $message->page_url, PHP_URL_HOST)
?: (string) config('domains.domains.'.$message->portal.'.domain_name', $message->portal);
}
}