From 06d2889a4d4d40883b50e94824dac28ff8f18772 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 9 Oct 2026 08:49:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Spamschutz=20f=C3=BCr=20Kontakt-Widget?= =?UTF-8?q?=20und=20Registrierung=20mit=20Cloudflare=20Turnstile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, ); 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 --- .env.example | 12 + .../Commands/PruneBotRegistrations.php | 145 +++++++++ .../Commands/QuarantineContactSpam.php | 60 ++++ app/Enums/ActivityType.php | 5 + app/Enums/BotCheckStatus.php | 27 ++ app/Enums/ContactMessageStatus.php | 4 + .../Controllers/Web/ContactController.php | 93 ++++-- app/Models/ContactMessage.php | 87 ++++++ app/Observers/ActivityLogObserver.php | 3 + app/Rules/NoPromotionalText.php | 2 +- app/Services/Admin/AdminSystemHealth.php | 29 ++ app/Services/Spam/BotCheck.php | 27 ++ app/Services/Spam/ContactSpamFilter.php | 136 +++++++++ app/Services/Spam/GibberishDetector.php | 44 +++ app/Services/Spam/SpamLog.php | 29 ++ app/Services/Spam/TurnstileVerifier.php | 151 +++++++++ app/Services/Web/ContactMessageNotifier.php | 40 +++ app/Support/ContentSecurityPolicy.php | 6 +- config/auth.php | 17 ++ config/logging.php | 6 + config/services.php | 15 + lang/de/web.php | 2 + lang/en/web.php | 2 + phpunit.xml | 2 + .../views/components/turnstile.blade.php | 133 ++++++++ .../components/web/contact-launcher.blade.php | 9 + .../admin/contact-messages/index.blade.php | 77 ++++- .../admin/contact-messages/show.blade.php | 49 +++ .../views/livewire/auth/register.blade.php | 51 +++- resources/views/web/datenschutz.blade.php | 43 +++ routes/console.php | 19 +- .../Admin/ContactMessagesAdminTest.php | 102 +++++++ tests/Feature/Admin/SystemHealthTest.php | 30 ++ .../Auth/PruneBotRegistrationsTest.php | 89 ++++++ .../Auth/RegistrationSpamProtectionTest.php | 132 ++++++++ tests/Feature/LogChannelsTest.php | 1 + .../Feature/Web/ContactSpamProtectionTest.php | 287 ++++++++++++++++++ .../Feature/Web/QuarantineContactSpamTest.php | 43 +++ tests/TestCase.php | 2 +- tests/Unit/GibberishDetectorTest.php | 21 ++ 40 files changed, 1992 insertions(+), 40 deletions(-) create mode 100644 app/Console/Commands/PruneBotRegistrations.php create mode 100644 app/Console/Commands/QuarantineContactSpam.php create mode 100644 app/Enums/BotCheckStatus.php create mode 100644 app/Services/Spam/BotCheck.php create mode 100644 app/Services/Spam/ContactSpamFilter.php create mode 100644 app/Services/Spam/GibberishDetector.php create mode 100644 app/Services/Spam/SpamLog.php create mode 100644 app/Services/Spam/TurnstileVerifier.php create mode 100644 app/Services/Web/ContactMessageNotifier.php create mode 100644 resources/views/components/turnstile.blade.php create mode 100644 tests/Feature/Auth/PruneBotRegistrationsTest.php create mode 100644 tests/Feature/Auth/RegistrationSpamProtectionTest.php create mode 100644 tests/Feature/Web/ContactSpamProtectionTest.php create mode 100644 tests/Feature/Web/QuarantineContactSpamTest.php create mode 100644 tests/Unit/GibberishDetectorTest.php diff --git a/.env.example b/.env.example index 3aae8d25..9706dd93 100644 --- a/.env.example +++ b/.env.example @@ -355,3 +355,15 @@ IMAGE_PRECHECK_STAGE=1 # SECURITY_HSTS_MAX_AGE=31536000 # CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log. # SECURITY_CSP_REPORT_ONLY= + +# Spamschutz mit Cloudflare Turnstile (Kontakt-Widget, Registrierung). Ohne beide Keys aus. +# Lokal die Testkeys von Cloudflare verwenden (1x00000000000000000000AA / 1x0000000000000000000000000000000AA), +# die echten Keys sind an die Live-Hostnamen gebunden. Ist Cloudflare nicht erreichbar, +# wird durchgelassen und markiert; TURNSTILE_FAIL_OPEN=false lehnt stattdessen ab. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +# TURNSTILE_TIMEOUT=3 +# TURNSTILE_FAIL_OPEN=true +# Unbestätigte Bot-Registrierungen nach 14 Tagen löschen (users:prune-bot-registrations). +# Erst einschalten, nachdem der Probelauf auf Live geprüft ist. +# USERS_PRUNE_BOT_REGISTRATIONS=false diff --git a/app/Console/Commands/PruneBotRegistrations.php b/app/Console/Commands/PruneBotRegistrations.php new file mode 100644 index 00000000..3a362c77 --- /dev/null +++ b/app/Console/Commands/PruneBotRegistrations.php @@ -0,0 +1,145 @@ +option('days') ?? config('auth.prune_bot_registrations.after_days', 14)); + + if ($days < 7) { + $this->error('Die Frist muss mindestens 7 Tage betragen.'); + + return self::FAILURE; + } + + $candidates = $this->candidates($days)->limit((int) $this->option('limit'))->get(); + + if ($candidates->isEmpty()) { + $this->info('Keine Bot-Registrierungen zu löschen.'); + + return self::SUCCESS; + } + + if ($this->option('dry-run')) { + $this->table( + ['ID', 'Angelegt', 'Name', 'E-Mail', 'Zufallsname'], + $candidates->map(fn (User $user): array => [ + $user->id, + $user->created_at?->format('d.m.Y H:i'), + Str::limit((string) $user->name, 30), + $this->maskEmail((string) $user->email), + GibberishDetector::isRandomPersonName((string) $user->name) ? 'ja' : '', + ])->all(), + ); + $this->info(sprintf( + 'Probelauf: %d Konten älter als %d Tage würden gelöscht, davon %d mit Zufallsnamen.', + $candidates->count(), + $days, + $candidates->filter(fn (User $user): bool => GibberishDetector::isRandomPersonName((string) $user->name))->count(), + )); + + return self::SUCCESS; + } + + $deleted = 0; + $failed = 0; + + foreach ($candidates as $user) { + try { + DB::transaction(fn () => $user->forceDelete()); + $deleted++; + } catch (\Throwable $exception) { + $failed++; + Log::channel('spam')->warning('Bot-Registrierung konnte nicht gelöscht werden.', [ + 'user_id' => $user->id, + 'error' => $exception->getMessage(), + ]); + } + } + + Log::channel('spam')->info('Bot-Registrierungen gelöscht.', ['deleted' => $deleted, 'failed' => $failed, 'days' => $days]); + $this->info("Bot-Registrierungen gelöscht: {$deleted}".($failed > 0 ? ", {$failed} fehlgeschlagen (siehe spam-Log)" : '').'.'); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return Builder + */ + private function candidates(int $days): Builder + { + $query = User::query() + ->whereNull('legacy_id') + ->whereNull('email_verified_at') + ->whereNull('last_login_at') + ->whereNull('oauth_provider') + ->where('is_active', false) + ->where('is_super_admin', false) + ->where('created_at', '<', now()->subDays($days)) + ->whereDoesntHave('roles') + ->whereNotExists(fn (QueryBuilder $query) => $query + ->from('personal_access_tokens') + ->whereColumn('personal_access_tokens.tokenable_id', 'users.id') + ->where('personal_access_tokens.tokenable_type', (new User)->getMorphClass())); + + foreach (self::USAGE_TABLES as $table) { + $query->whereNotExists(fn (QueryBuilder $sub) => $sub + ->from($table) + ->whereColumn($table.'.user_id', 'users.id')); + } + + return $query->orderBy('id'); + } + + private function maskEmail(string $email): string + { + [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); + + return Str::substr($local, 0, 2).'***@'.$domain; + } +} diff --git a/app/Console/Commands/QuarantineContactSpam.php b/app/Console/Commands/QuarantineContactSpam.php new file mode 100644 index 00000000..76e595a4 --- /dev/null +++ b/app/Console/Commands/QuarantineContactSpam.php @@ -0,0 +1,60 @@ +open() + ->whereNull('user_id') + ->orderBy('id') + ->get() + ->map(fn (ContactMessage $message): array => [ + 'message' => $message, + // Ohne Zeitstempel: Der Altbestand hatte kein Formular-Feld dafür. + 'reason' => $filter->reasonFor((string) $message->name, (string) $message->message, null), + ]) + ->filter(fn (array $hit): bool => $hit['reason'] !== null); + + $this->table( + ['Vorgang', 'Eingang', 'Grund', 'Nachricht'], + $hits->map(fn (array $hit): array => [ + $hit['message']->reference(), + $hit['message']->created_at?->format('d.m.Y'), + $hit['reason'], + Str::limit(Str::squish((string) $hit['message']->message), 60), + ])->all(), + ); + + if ($this->option('dry-run')) { + $this->info("Probelauf: {$hits->count()} Anliegen würden als Spam markiert."); + + return self::SUCCESS; + } + + foreach ($hits as $hit) { + $hit['message']->markAsSpam(null, $hit['reason']); + } + + $this->info("{$hits->count()} Anliegen als Spam markiert. Offen bleiben: ".ContactMessage::query()->open()->count().'.'); + + return self::SUCCESS; + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 2cdb356e..ac3bd6f4 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -44,6 +44,9 @@ enum ActivityType: string case TwoFactorFailed = 'two_factor_failed'; + /** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */ + case BotCheckUnavailable = 'bot_check_unavailable'; + public function label(): string { return match ($this) { @@ -73,6 +76,7 @@ enum ActivityType: string self::LoginFailed => 'Anmeldung fehlgeschlagen', self::LoginLockout => 'Anmeldesperre', self::TwoFactorFailed => '2FA fehlgeschlagen', + self::BotCheckUnavailable => 'Ohne Bot-Prüfung', }; } @@ -102,6 +106,7 @@ enum ActivityType: string self::ManualInvoice => 'amber', self::LoginFailed, self::TwoFactorFailed => 'orange', self::LoginLockout => 'red', + self::BotCheckUnavailable => 'orange', }; } } diff --git a/app/Enums/BotCheckStatus.php b/app/Enums/BotCheckStatus.php new file mode 100644 index 00000000..507262fd --- /dev/null +++ b/app/Enums/BotCheckStatus.php @@ -0,0 +1,27 @@ + 'In Bearbeitung', self::Resolved => 'Erledigt', self::Rejected => 'Abgelehnt', + self::Spam => 'Spam', }; } diff --git a/app/Http/Controllers/Web/ContactController.php b/app/Http/Controllers/Web/ContactController.php index 1bf3f243..c8fc9bc8 100644 --- a/app/Http/Controllers/Web/ContactController.php +++ b/app/Http/Controllers/Web/ContactController.php @@ -2,14 +2,21 @@ namespace App\Http\Controllers\Web; +use App\Enums\ActivityType; +use App\Enums\BotCheckStatus; +use App\Enums\ContactMessageStatus; use App\Enums\ContactMessageTopic; use App\Http\Controllers\Controller; -use App\Mail\ContactRequestSubmitted; use App\Models\ContactMessage; +use App\Services\ActivityLog; +use App\Services\Spam\ContactSpamFilter; +use App\Services\Spam\SpamLog; +use App\Services\Spam\TurnstileVerifier; +use App\Services\Web\ContactMessageNotifier; use App\Support\WebPortalContext; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException; * (contact_messages) und versendet zusätzlich eine Mail an die Brand- * Kontaktadresse (config domains.*.brand.contact_email, Fallback: * Absenderadresse der Anwendung). + * + * Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md): + * Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters + * landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht + * erreichbar, wird angenommen und im Verlauf markiert. */ class ContactController extends Controller { + public function __construct( + private readonly TurnstileVerifier $turnstile, + private readonly ContactSpamFilter $spamFilter, + private readonly ContactMessageNotifier $notifier, + private readonly ActivityLog $activityLog, + ) {} + public function __invoke(Request $request): RedirectResponse { // Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken, @@ -53,27 +72,66 @@ class ContactController extends Controller $this->ensureNotRateLimited($request); RateLimiter::hit($this->throttleKey($request), 3600); + // Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort + // landen die Bot-Registrierungen) prüfen wir wie Gäste. + $trusted = $request->user()?->hasVerifiedEmail() === true; + + $botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact'); + + if ($botCheck?->blocks()) { + throw ValidationException::withMessages([ + 'turnstile' => __('web.launcher.bot_check_failed'), + ]); + } + + $spamReason = $trusted ? null : $this->spamFilter->reasonFor( + $validated['name'], + $validated['message'], + $request->string(ContactSpamFilter::STARTED_FIELD)->toString(), + ); + $topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact; - ContactMessage::create([ + $message = ContactMessage::create([ 'portal' => $this->portal($request), 'topic' => $topic, + 'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open, 'name' => $validated['name'], 'email' => $validated['email'], 'message' => $validated['message'], 'page_url' => $validated['page_url'] ?? null, + 'payload' => array_filter([ + 'spam_reason' => $spamReason, + 'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value, + 'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null, + ]) ?: null, 'requester_ip' => $request->ip(), 'user_id' => $request->user()?->id, ]); - Mail::to($this->recipient())->queue((new ContactRequestSubmitted( - senderName: $validated['name'], - senderEmail: $validated['email'], - messageText: $validated['message'], - host: $request->getHost(), - topicLabel: $topic->label(), - pageUrl: $validated['page_url'] ?? null, - ))->afterCommit()); + // Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender + // bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts. + if ($spamReason !== null) { + Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [ + 'reason' => $spamReason, + 'portal' => $message->portal, + 'ip_prefix' => SpamLog::ipPrefix($request->ip()), + ]); + + return $this->done(); + } + + if ($botCheck?->needsReview()) { + $this->activityLog->record( + ActivityType::BotCheckUnavailable, + sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason), + subject: $message, + actorLabel: (string) $message->email, + meta: ['form' => 'contact', 'reason' => $botCheck->reason], + ); + } + + $this->notifier->notify($message, $request->getHost()); return $this->done(); } @@ -96,19 +154,6 @@ class ContactController extends Controller return WebPortalContext::portalFromHost()->value; } - private function recipient(): string - { - // Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein - // Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden. - if (str_contains(request()->getHost(), 'pressekonto')) { - $brand = config('domains.domains.pressekonto.brand', []); - } else { - $brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? []; - } - - return $brand['contact_email'] ?? config('mail.from.address'); - } - private function ensureNotRateLimited(Request $request): void { if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { diff --git a/app/Models/ContactMessage.php b/app/Models/ContactMessage.php index 3aa9d7ea..1a54bdaf 100644 --- a/app/Models/ContactMessage.php +++ b/app/Models/ContactMessage.php @@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic; use Database\Factories\ContactMessageFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,11 @@ class ContactMessage extends Model /** @use HasFactory */ use HasFactory; + use MassPrunable; + + /** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */ + public const SPAM_RETENTION_DAYS = 30; + protected $fillable = [ 'portal', 'topic', @@ -47,6 +53,87 @@ class ContactMessage extends Model ]; } + /** + * Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune). + * + * @return Builder + */ + public function prunable(): Builder + { + return self::query() + ->where('status', ContactMessageStatus::Spam->value) + ->where('created_at', '<', now()->subDays(self::SPAM_RETENTION_DAYS)); + } + + public function isSpam(): bool + { + return $this->status === ContactMessageStatus::Spam; + } + + /** + * In die Spam-Quarantäne legen (Admin, Sammelaktion oder Altbestand). + * Ein vorhandener Filtergrund bleibt erhalten. + */ + public function markAsSpam(?User $by, string $reason = 'manual'): void + { + $this->update([ + 'status' => ContactMessageStatus::Spam->value, + 'payload' => [...($this->payload ?? []), 'spam_reason' => $this->payload['spam_reason'] ?? $reason], + 'resolved_by_user_id' => $by?->id, + 'resolved_at' => now(), + ]); + } + + /** + * Fehlalarm: zurück in die offene Queue. Gibt zurück, ob die Team-Mail + * noch aussteht: Hat der Filter die Anfrage beim Eingang abgefangen, ging + * keine raus — beim ersten Zurückholen wird sie nachgeholt. Von Hand + * markierter Spam hatte seine Mail schon. + */ + public function releaseFromSpam(): bool + { + $payload = $this->payload ?? []; + $mailPending = isset($payload['spam_reason']) + && $payload['spam_reason'] !== 'manual' + && ! isset($payload['released_from_spam_at']); + + $this->update([ + 'status' => ContactMessageStatus::Open->value, + 'payload' => [...$payload, 'released_from_spam_at' => now()->toIso8601String()], + 'resolved_by_user_id' => null, + 'resolved_at' => null, + ]); + + return $mailPending; + } + + /** + * Grund der Quarantäne für die Admin-Ansicht (ContactSpamFilter). + */ + public function spamReasonLabel(): ?string + { + $reason = $this->payload['spam_reason'] ?? null; + + return match ($reason) { + null => null, + 'link' => 'Link im Text', + 'page_title' => 'Floskel mit Seitentitel', + 'gibberish' => 'Zeichensalat', + 'blacklist' => 'Begriff der Sperrliste', + 'too_fast' => 'Zu schnell abgesendet', + 'manual' => 'Von Hand markiert', + default => (string) $reason, + }; + } + + /** + * Angenommen, obwohl Cloudflare Turnstile nicht erreichbar war. + */ + public function skippedBotCheck(): bool + { + return ($this->payload['bot_check'] ?? null) === 'unavailable'; + } + /** * Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}"). */ diff --git a/app/Observers/ActivityLogObserver.php b/app/Observers/ActivityLogObserver.php index 4f463852..13b3b021 100644 --- a/app/Observers/ActivityLogObserver.php +++ b/app/Observers/ActivityLogObserver.php @@ -3,6 +3,7 @@ namespace App\Observers; use App\Enums\ActivityType; +use App\Enums\ContactMessageStatus; use App\Models\ContactMessage; use App\Models\LegalRequest; use App\Models\NewsletterSubscription; @@ -23,6 +24,8 @@ class ActivityLogObserver public function created(Model $model): void { match (true) { + // Spam-Quarantäne gehört nicht in den Verlauf. + $model instanceof ContactMessage && $model->status === ContactMessageStatus::Spam => null, $model instanceof ContactMessage => $this->activityLog->record( ActivityType::ContactRequest, sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email), diff --git a/app/Rules/NoPromotionalText.php b/app/Rules/NoPromotionalText.php index 07e0dd32..4832672d 100644 --- a/app/Rules/NoPromotionalText.php +++ b/app/Rules/NoPromotionalText.php @@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule; class NoPromotionalText implements ValidationRule { /** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */ - private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu'; + public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu'; private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu'; diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 9c01b05e..0832ad4f 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -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. diff --git a/app/Services/Spam/BotCheck.php b/app/Services/Spam/BotCheck.php new file mode 100644 index 00000000..0f92b3d0 --- /dev/null +++ b/app/Services/Spam/BotCheck.php @@ -0,0 +1,27 @@ +status->blocks(); + } + + public function needsReview(): bool + { + return $this->status === BotCheckStatus::Unavailable; + } +} diff --git a/app/Services/Spam/ContactSpamFilter.php b/app/Services/Spam/ContactSpamFilter.php new file mode 100644 index 00000000..9580fc4a --- /dev/null +++ b/app/Services/Spam/ContactSpamFilter.php @@ -0,0 +1,136 @@ +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; + } +} diff --git a/app/Services/Spam/GibberishDetector.php b/app/Services/Spam/GibberishDetector.php new file mode 100644 index 00000000..031ba6f7 --- /dev/null +++ b/app/Services/Spam/GibberishDetector.php @@ -0,0 +1,44 @@ += 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; + } +} diff --git a/app/Services/Spam/SpamLog.php b/app/Services/Spam/SpamLog.php new file mode 100644 index 00000000..5b84facc --- /dev/null +++ b/app/Services/Spam/SpamLog.php @@ -0,0 +1,29 @@ +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')); + } +} diff --git a/app/Services/Web/ContactMessageNotifier.php b/app/Services/Web/ContactMessageNotifier.php new file mode 100644 index 00000000..0b883d4f --- /dev/null +++ b/app/Services/Web/ContactMessageNotifier.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Support/ContentSecurityPolicy.php b/app/Support/ContentSecurityPolicy.php index dc3ad8ae..ee81d6d9 100644 --- a/app/Support/ContentSecurityPolicy.php +++ b/app/Support/ContentSecurityPolicy.php @@ -23,17 +23,19 @@ class ContentSecurityPolicy $own = array_merge(["'self'"], self::ownOrigins()); $umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null; $errors = self::originOf((string) config('monitoring.browser.dsn')); + // Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe. + $turnstile = 'https://challenges.cloudflare.com'; $directives = [ 'default-src' => ["'self'"], - 'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami], + 'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami, $turnstile], 'style-src' => [...$own, "'unsafe-inline'"], 'img-src' => [...$own, 'data:', 'blob:', 'https:'], 'font-src' => [...$own, 'data:'], 'connect-src' => [...$own, $umami, $errors], 'media-src' => [...$own], 'object-src' => ["'none'"], - 'frame-src' => ["'self'"], + 'frame-src' => ["'self'", $turnstile], 'frame-ancestors' => ["'self'"], 'base-uri' => ["'self'"], 'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'], diff --git a/config/auth.php b/config/auth.php index 6d45b332..e7af1ffe 100644 --- a/config/auth.php +++ b/config/auth.php @@ -147,4 +147,21 @@ return [ 'password' => env('BASIC_AUTH_PASSWORD'), ], + /* + |-------------------------------------------------------------------------- + | Bot-Registrierungen aufräumen (Spamschutz B4) + |-------------------------------------------------------------------------- + | + | `users:prune-bot-registrations` löscht über das Formular angelegte + | Konten, die nach `after_days` weder bestätigt noch genutzt wurden. + | Der tägliche Lauf ist aus, bis der Probelauf (--dry-run) auf Live + | geprüft ist; Altkonten aus dem Legacy-Import sind nie betroffen. + | + */ + + 'prune_bot_registrations' => [ + 'enabled' => (bool) env('USERS_PRUNE_BOT_REGISTRATIONS', false), + 'after_days' => 14, + ], + ]; diff --git a/config/logging.php b/config/logging.php index d074d3c4..93db3dda 100644 --- a/config/logging.php +++ b/config/logging.php @@ -104,6 +104,8 @@ return [ | monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene | Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces | market Kursdaten-Abruf (market:refresh) + | csp CSP-Meldungen der Browser (Melde-Modus) + | spam Spamschutz: abgewiesene und ungeprüfte Formulare | admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte) | */ @@ -119,6 +121,10 @@ return [ // CSP-Meldungen der Browser (Melde-Modus, CspReportController). 'csp' => $topicChannel('csp'), + // Spamschutz: abgewiesene und ungeprüfte Formulare, nur Grund und + // Netz-Präfix (TurnstileVerifier, ContactController). + 'spam' => $topicChannel('spam'), + 'admin_slow' => [ 'driver' => 'daily', 'path' => storage_path('logs/admin-slow.log'), diff --git a/config/services.php b/config/services.php index 3b2ad398..6dc4cd59 100644 --- a/config/services.php +++ b/config/services.php @@ -64,4 +64,19 @@ return [ 'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false), 'identify_users' => env('UMAMI_IDENTIFY_USERS', false), ], + + /* + | Cloudflare Turnstile (Spamschutz, dev/sicherheit/ENTWICKLUNGSPLAN- + | SPAMSCHUTZ-2026-10-09.md): Bot-Prüfung für Kontakt-Widget und + | Registrierung. Ohne beide Keys ist die Prüfung aus (Tests, lokal ohne + | Keys). Lokal die Cloudflare-Testkeys verwenden — die echten Keys sind an + | die Live-Hostnamen gebunden. `fail_open`: Ist Cloudflare nicht + | erreichbar, wird durchgelassen und markiert (Entscheidung 09.10.2026). + */ + 'turnstile' => [ + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'timeout' => (int) env('TURNSTILE_TIMEOUT', 3), + 'fail_open' => (bool) env('TURNSTILE_FAIL_OPEN', true), + ], ]; diff --git a/lang/de/web.php b/lang/de/web.php index bb8f591b..03beb0ca 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -224,6 +224,8 @@ return [ 'validation_message' => 'Bitte geben Sie eine Nachricht ein.', 'validation_message_min' => 'Ihre Nachricht sollte mindestens :min Zeichen lang sein.', 'validation_message_max' => 'Ihre Nachricht darf höchstens :max Zeichen lang sein.', + 'bot_check_failed' => 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte laden Sie die Seite neu und senden Sie Ihre Nachricht erneut.', + 'bot_check_waiting' => 'Sicherheitsprüfung läuft …', ], 'search' => [ diff --git a/lang/en/web.php b/lang/en/web.php index 86ea4812..ba4eef3a 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -219,6 +219,8 @@ return [ 'validation_message' => 'Please enter a message.', 'validation_message_min' => 'Your message should be at least :min characters long.', 'validation_message_max' => 'Your message must not exceed :max characters.', + 'bot_check_failed' => 'The security check failed. Please reload the page and send your message again.', + 'bot_check_waiting' => 'Running security check …', ], 'search' => [ diff --git a/phpunit.xml b/phpunit.xml index 8d8c6946..abfc1d43 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -52,6 +52,8 @@ + + diff --git a/resources/views/components/turnstile.blade.php b/resources/views/components/turnstile.blade.php new file mode 100644 index 00000000..b7e8a96d --- /dev/null +++ b/resources/views/components/turnstile.blade.php @@ -0,0 +1,133 @@ +{{-- + Cloudflare Turnstile (Spamschutz B1, dev/sicherheit/ENTWICKLUNGSPLAN- + SPAMSCHUTZ-2026-10-09.md). Serverseitig prüft TurnstileVerifier. + + - Das Skript lädt erst, wenn das Widget sichtbar wird (Kontakt-Widget: + beim Öffnen) — normale Seitenaufrufe sprechen Cloudflare nicht an. + - Klassisches Formular: Turnstile legt das Feld `cf-turnstile-response` + selbst an. Wird abgeschickt, bevor das Token da ist, wartet das Formular + und sendet automatisch nach. + - Livewire: `wire-token` / `wire-unavailable` nennen die Properties, die + das Widget ohne Request setzt; `turnstile-reset` (Browser-Event) holt + nach einer abgelehnten Prüfung ein frisches Token. + - Lädt das Skript nicht (Cloudflare nicht erreichbar, Netz blockiert) + oder ist das Widget falsch konfiguriert, meldet es `turnstile_unavailable`: + Der Server lässt dann durch und markiert (Entscheidung 09.10.2026). + Nicht bestandene Challenges (Fehlercodes 300xxx/600xxx) zählen nicht + als Ausfall — Turnstile versucht es selbst erneut. +--}} +@props([ + 'action', + 'wireToken' => null, + 'wireUnavailable' => null, +]) + +@if (\App\Services\Spam\TurnstileVerifier::enabled()) +
class('min-h-[65px]') }} + > +
+ +

{{ __('web.launcher.bot_check_waiting') }}

+
+@endif diff --git a/resources/views/components/web/contact-launcher.blade.php b/resources/views/components/web/contact-launcher.blade.php index 79d209e4..b209b4fd 100644 --- a/resources/views/components/web/contact-launcher.blade.php +++ b/resources/views/components/web/contact-launcher.blade.php @@ -163,6 +163,15 @@ {{ __('web.launcher.legal_link') }}

+ {{-- Spamschutz für Gäste und unbestätigte Konten (siehe ContactController). --}} + @unless (auth()->user()?->hasVerifiedEmail()) + + @error('turnstile') +

{{ $message }}

+ @enderror + + @endunless + {{-- Bot-Schutz: versteckte Honeypot-Felder (siehe ContactController). --}}