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). --}}
diff --git a/resources/views/livewire/admin/contact-messages/index.blade.php b/resources/views/livewire/admin/contact-messages/index.blade.php
index fffbea64..893e5ffb 100644
--- a/resources/views/livewire/admin/contact-messages/index.blade.php
+++ b/resources/views/livewire/admin/contact-messages/index.blade.php
@@ -19,13 +19,41 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
#[Url(as: 'topic', except: 'all')]
public string $topicFilter = 'all';
+ /** @var list
Auswahl für die Sammelaktion „Als Spam markieren“. */
+ public array $selected = [];
+
+ public string $notification = '';
+
public function updatedStatusFilter(): void
{
+ $this->selected = [];
$this->resetPage();
}
+ /**
+ * Sammelaktion: ausgewählte Anliegen in die Spam-Quarantäne legen
+ * (Spamschutz B3). Keine Mail, Löschung nach 30 Tagen.
+ */
+ public function markSelectedAsSpam(): void
+ {
+ $this->authorize('compliance:manage');
+
+ $messages = ContactMessage::query()
+ ->whereKey(array_map('intval', $this->selected))
+ ->where('status', '!=', ContactMessageStatus::Spam->value)
+ ->get();
+
+ foreach ($messages as $message) {
+ $message->markAsSpam(auth()->user());
+ }
+
+ $this->selected = [];
+ $this->notification = __(':count Anliegen als Spam markiert.', ['count' => $messages->count()]);
+ }
+
public function updatedTopicFilter(): void
{
+ $this->selected = [];
$this->resetPage();
}
@@ -42,7 +70,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
'messages' => $messages,
'topicOptions' => ContactMessageTopic::cases(),
'openCount' => ContactMessage::query()->open()->count(),
- 'totalCount' => ContactMessage::query()->count(),
+ 'spamCount' => ContactMessage::query()->where('status', ContactMessageStatus::Spam->value)->count(),
+ 'totalCount' => ContactMessage::query()->where('status', '!=', ContactMessageStatus::Spam->value)->count(),
];
}
@@ -53,6 +82,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
+ ContactMessageStatus::Spam => 'muted',
};
}
}; ?>
@@ -67,14 +97,18 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
/>
{{-- ============== KPI-Reihe ============== --}}
-
+
{{ __('zu bearbeiten') }}
{{ __('offen + in Bearbeitung') }}
{{ __('alle Anliegen') }}
- {{ __('inkl. erledigt') }}
+ {{ __('inkl. erledigt, ohne Spam') }}
+
+
+ {{ __('in Quarantäne') }}
+ {{ __('ohne Mail, nach :days Tagen gelöscht', ['days' => \App\Models\ContactMessage::SPAM_RETENTION_DAYS]) }}
@@ -89,6 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
+
@@ -104,12 +139,28 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
{{ __('Anliegen') }}
-
- {{ __(':count Einträge', ['count' => $messages->total()]) }}
-
+
+ @if (count($selected) > 0)
+
+ {{ __(':count als Spam markieren', ['count' => count($selected)]) }}
+
+ @endif
+
+ {{ __(':count Einträge', ['count' => $messages->total()]) }}
+
+
+ @if ($notification)
+
+
+ {{ $notification }}
+
+ @endif
+ {{ __('Auswahl') }}
{{ __('Anliegen') }}
{{ __('Status') }}
{{ __('Absender') }}
@@ -121,6 +172,13 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
@forelse ($messages as $message)
+
+ @unless ($message->isSpam())
+
+ @endunless
+
{{ $message->topic->label() }}
@@ -133,6 +191,11 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
{{ $message->status->label() }}
+ @if ($message->isSpam() && $message->spamReasonLabel())
+ {{ $message->spamReasonLabel() }}
+ @elseif ($message->skippedBotCheck())
+ {{ __('ohne Bot-Prüfung') }}
+ @endif
@@ -161,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
@empty
-
+
diff --git a/resources/views/livewire/admin/contact-messages/show.blade.php b/resources/views/livewire/admin/contact-messages/show.blade.php
index 981146ef..337a5c4e 100644
--- a/resources/views/livewire/admin/contact-messages/show.blade.php
+++ b/resources/views/livewire/admin/contact-messages/show.blade.php
@@ -2,6 +2,7 @@
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage;
+use App\Services\Web\ContactMessageNotifier;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@@ -56,6 +57,34 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
$this->notification = __('Anliegen wieder geöffnet.');
}
+ public function markAsSpam(): void
+ {
+ $this->authorize('compliance:manage');
+
+ $this->message()->markAsSpam(auth()->user());
+ $this->notification = __('Als Spam markiert. Nach :days Tagen wird das Anliegen gelöscht.', ['days' => ContactMessage::SPAM_RETENTION_DAYS]);
+ }
+
+ /**
+ * Fehlalarm der Spam-Quarantäne: wieder öffnen und die Team-Mail
+ * nachholen, die beim Eingang zurückgehalten wurde.
+ */
+ public function releaseFromSpam(ContactMessageNotifier $notifier): void
+ {
+ $this->authorize('compliance:manage');
+
+ $message = $this->message();
+
+ if ($message->releaseFromSpam()) {
+ $notifier->notify($message->refresh());
+ $this->notification = __('Kein Spam: Anliegen wieder geöffnet, die Mail an das Team wurde nachgeholt.');
+
+ return;
+ }
+
+ $this->notification = __('Kein Spam: Anliegen wieder geöffnet.');
+ }
+
private function finish(ContactMessageStatus $status, string $notification): void
{
$this->message()->update([
@@ -85,6 +114,7 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
+ ContactMessageStatus::Spam => 'muted',
};
}
}; ?>
@@ -155,6 +185,18 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
{{ __('Portal') }}
{{ $message->portal ?: '—' }}
+ @if ($message->spamReasonLabel())
+
+
{{ __('Spamschutz') }}
+ {{ $message->spamReasonLabel() }}
+
+ @endif
+ @if ($message->skippedBotCheck())
+
+
{{ __('Bot-Prüfung') }}
+ {{ __('Cloudflare nicht erreichbar — bitte prüfen') }}
+
+ @endif
@if ($message->user)
{{ __('Benutzerkonto') }}
@@ -217,6 +259,13 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
{{ __('Ablehnen') }}
+
+ {{ __('Als Spam markieren') }}
+
+ @elseif ($message->isSpam())
+
+ {{ __('Kein Spam — wieder öffnen') }}
+
@else
{{ __('Wieder öffnen') }}
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php
index 8fb165b0..c64db954 100644
--- a/resources/views/livewire/auth/register.blade.php
+++ b/resources/views/livewire/auth/register.blade.php
@@ -1,7 +1,11 @@
'Konto erstell
public bool $terms_accepted = false;
+ /** Vom Turnstile-Widget gesetzt (Spamschutz B4). */
+ public string $turnstileToken = '';
+
+ public bool $turnstileUnavailable = false;
+
/**
* Handle an incoming registration request.
*/
- public function register(): void
+ public function register(TurnstileVerifier $turnstile, ActivityLog $activityLog): void
{
$validated = $this->validate([
- 'name' => ['required', 'string', 'max:255'],
+ // Bots registrieren mit Zufallsnamen („ruLrlotwaCoVStmZetSxD“),
+ // 389 Konten in 30 Tagen bis 07.10.2026 (Spamschutz B4).
+ 'name' => ['required', 'string', 'max:255', function (string $attribute, mixed $value, \Closure $fail): void {
+ if (is_string($value) && GibberishDetector::isRandomPersonName($value)) {
+ $fail(__('Bitte geben Sie Ihren Vor- und Nachnamen an.'));
+ }
+ }],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
'terms_accepted' => ['accepted'],
@@ -46,6 +61,21 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
RateLimiter::hit('register-ip|'.request()->ip(), 3600);
+ // Jede Registrierung verschickt eine Bestätigungsmail an die
+ // angegebene Adresse — ohne Bot-Prüfung ein Werkzeug, fremde
+ // Postfächer zu fluten. Ist Cloudflare nicht erreichbar, wird
+ // durchgelassen und im Verlauf markiert (Entscheidung 09.10.2026).
+ $botCheck = $turnstile->verify($this->turnstileToken, $this->turnstileUnavailable, 'register', request()->ip());
+
+ if ($botCheck->blocks()) {
+ $this->turnstileToken = '';
+ $this->dispatch('turnstile-reset');
+
+ throw ValidationException::withMessages([
+ 'turnstile' => __('Die Sicherheitsprüfung ist fehlgeschlagen. Bitte warten Sie einen Moment und versuchen Sie es erneut.'),
+ ]);
+ }
+
// Die bestätigte AGB-/Datenschutz-Checkbox wird als DSGVO-Einwilligung
// mit Zeitstempel festgehalten (users.gdpr_consent_at).
unset($validated['terms_accepted']);
@@ -65,6 +95,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
// SendEmailVerificationNotification-Listener die Bestätigungsmail.
event(new Registered($user = User::create($validated)));
+ if ($botCheck->needsReview()) {
+ $activityLog->record(
+ ActivityType::BotCheckUnavailable,
+ sprintf('Registrierung von %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $user->email, $botCheck->reason),
+ user: $user,
+ subject: $user,
+ meta: ['form' => 'register', 'reason' => $botCheck->reason],
+ );
+ }
+
Auth::login($user);
// Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das
@@ -151,6 +191,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
@enderror
+
+
+ @error('turnstile')
+
{{ $message }}
+ @enderror
+
+