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

@ -355,3 +355,15 @@ IMAGE_PRECHECK_STAGE=1
# SECURITY_HSTS_MAX_AGE=31536000 # SECURITY_HSTS_MAX_AGE=31536000
# CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log. # CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log.
# SECURITY_CSP_REPORT_ONLY= # 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

View file

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Spam\GibberishDetector;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Löscht Bot-Registrierungen (Spamschutz B4): 389 Konten in 30 Tagen bis
* 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
*
* Gelöscht wird nur, was über das Registrierungsformular entstand und nie
* genutzt wurde — alle Bedingungen zugleich:
* - keine Legacy-ID (66.870 unbestätigte Altkonten bleiben unberührt),
* - E-Mail nie bestätigt, Konto inaktiv, nie angemeldet, kein Google-Login,
* - älter als die Frist (Standard 14 Tage),
* - keine Firma, kein Kontakt, keine Meldung, keine Zahlung, kein Token.
*/
class PruneBotRegistrations extends Command
{
protected $signature = 'users:prune-bot-registrations
{--days= : Frist in Tagen (Standard: config auth.prune_bot_registrations.after_days)}
{--dry-run : Nur auflisten, nichts löschen}
{--limit=2000 : Höchstzahl je Lauf}';
protected $description = 'Löscht unbestätigte, nie genutzte Konten aus dem Registrierungsformular (Bot-Registrierungen).';
/** Tabellen, deren Einträge ein Konto als genutzt ausweisen. */
private const USAGE_TABLES = [
'company_user',
'contact_user',
'press_releases',
'invoices',
'legacy_invoices',
'subscriptions',
'single_purchases',
'credit_transactions',
'credit_topups',
'booking_confirmations',
'boosts',
'billing_addresses',
];
public function handle(): int
{
$days = (int) ($this->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<User>
*/
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;
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Models\ContactMessage;
use App\Services\Spam\ContactSpamFilter;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Einmalig für den Altbestand (Spamschutz, Entscheidung 2): offene
* Kontakt-Anliegen von Gästen nach denselben Regeln wie beim Eingang in die
* Spam-Quarantäne legen. Was die Regeln nicht erkennen, markiert der Admin
* über die Sammelaktion der Kontakt-Queue.
*/
class QuarantineContactSpam extends Command
{
protected $signature = 'contact-messages:quarantine-spam {--dry-run : Nur auflisten, nichts ändern}';
protected $description = 'Legt offene Kontakt-Anliegen von Gästen, die der Spamfilter erkennt, in die Quarantäne.';
public function handle(ContactSpamFilter $filter): int
{
$hits = ContactMessage::query()
->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;
}
}

View file

@ -44,6 +44,9 @@ enum ActivityType: string
case TwoFactorFailed = 'two_factor_failed'; 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 public function label(): string
{ {
return match ($this) { return match ($this) {
@ -73,6 +76,7 @@ enum ActivityType: string
self::LoginFailed => 'Anmeldung fehlgeschlagen', self::LoginFailed => 'Anmeldung fehlgeschlagen',
self::LoginLockout => 'Anmeldesperre', self::LoginLockout => 'Anmeldesperre',
self::TwoFactorFailed => '2FA fehlgeschlagen', self::TwoFactorFailed => '2FA fehlgeschlagen',
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
}; };
} }
@ -102,6 +106,7 @@ enum ActivityType: string
self::ManualInvoice => 'amber', self::ManualInvoice => 'amber',
self::LoginFailed, self::TwoFactorFailed => 'orange', self::LoginFailed, self::TwoFactorFailed => 'orange',
self::LoginLockout => 'red', self::LoginLockout => 'red',
self::BotCheckUnavailable => 'orange',
}; };
} }
} }

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
/**
* Ergebnis der Bot-Prüfung (Cloudflare Turnstile) eines öffentlichen
* Formulars, siehe TurnstileVerifier.
*/
enum BotCheckStatus: string
{
/** Cloudflare hat den Menschen bestätigt. */
case Passed = 'passed';
/** Token fehlt, ist ungültig, abgelaufen oder gehört zu einem anderen Formular. */
case Failed = 'failed';
/** Cloudflare war nicht erreichbar — durchgelassen und zur Nachprüfung markiert. */
case Unavailable = 'unavailable';
/** Prüfung abgeschaltet (keine Keys, z. B. in Tests). */
case Skipped = 'skipped';
public function blocks(): bool
{
return $this === self::Failed;
}
}

View file

@ -9,6 +9,9 @@ enum ContactMessageStatus: string
case Resolved = 'resolved'; case Resolved = 'resolved';
case Rejected = 'rejected'; case Rejected = 'rejected';
/** Quarantäne des Spamschutzes: keine Mail, nicht im Zähler, nach 30 Tagen gelöscht. */
case Spam = 'spam';
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
@ -16,6 +19,7 @@ enum ContactMessageStatus: string
self::InProgress => 'In Bearbeitung', self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt', self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt', self::Rejected => 'Abgelehnt',
self::Spam => 'Spam',
}; };
} }

View file

@ -2,14 +2,21 @@
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\ActivityType;
use App\Enums\BotCheckStatus;
use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic; use App\Enums\ContactMessageTopic;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage; 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 App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException;
* (contact_messages) und versendet zusätzlich eine Mail an die Brand- * (contact_messages) und versendet zusätzlich eine Mail an die Brand-
* Kontaktadresse (config domains.*.brand.contact_email, Fallback: * Kontaktadresse (config domains.*.brand.contact_email, Fallback:
* Absenderadresse der Anwendung). * 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 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 public function __invoke(Request $request): RedirectResponse
{ {
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken, // Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
@ -53,27 +72,66 @@ class ContactController extends Controller
$this->ensureNotRateLimited($request); $this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600); 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; $topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
ContactMessage::create([ $message = ContactMessage::create([
'portal' => $this->portal($request), 'portal' => $this->portal($request),
'topic' => $topic, 'topic' => $topic,
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], 'email' => $validated['email'],
'message' => $validated['message'], 'message' => $validated['message'],
'page_url' => $validated['page_url'] ?? null, '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(), 'requester_ip' => $request->ip(),
'user_id' => $request->user()?->id, 'user_id' => $request->user()?->id,
]); ]);
Mail::to($this->recipient())->queue((new ContactRequestSubmitted( // Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
senderName: $validated['name'], // bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
senderEmail: $validated['email'], if ($spamReason !== null) {
messageText: $validated['message'], Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
host: $request->getHost(), 'reason' => $spamReason,
topicLabel: $topic->label(), 'portal' => $message->portal,
pageUrl: $validated['page_url'] ?? null, 'ip_prefix' => SpamLog::ipPrefix($request->ip()),
))->afterCommit()); ]);
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(); return $this->done();
} }
@ -96,19 +154,6 @@ class ContactController extends Controller
return WebPortalContext::portalFromHost()->value; 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 private function ensureNotRateLimited(Request $request): void
{ {
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {

View file

@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic;
use Database\Factories\ContactMessageFactory; use Database\Factories\ContactMessageFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -21,6 +22,11 @@ class ContactMessage extends Model
/** @use HasFactory<ContactMessageFactory> */ /** @use HasFactory<ContactMessageFactory> */
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 = [ protected $fillable = [
'portal', 'portal',
'topic', 'topic',
@ -47,6 +53,87 @@ class ContactMessage extends Model
]; ];
} }
/**
* Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune).
*
* @return Builder<ContactMessage>
*/
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}"). * Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
*/ */

View file

@ -3,6 +3,7 @@
namespace App\Observers; namespace App\Observers;
use App\Enums\ActivityType; use App\Enums\ActivityType;
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage; use App\Models\ContactMessage;
use App\Models\LegalRequest; use App\Models\LegalRequest;
use App\Models\NewsletterSubscription; use App\Models\NewsletterSubscription;
@ -23,6 +24,8 @@ class ActivityLogObserver
public function created(Model $model): void public function created(Model $model): void
{ {
match (true) { 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( $model instanceof ContactMessage => $this->activityLog->record(
ActivityType::ContactRequest, ActivityType::ContactRequest,
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email), sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),

View file

@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
class NoPromotionalText implements ValidationRule class NoPromotionalText implements ValidationRule
{ {
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */ /** 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'; private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';

View file

@ -3,6 +3,8 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog; use App\Authorization\Permission as PermissionCatalog;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\ArchiveEntry; use App\Models\ArchiveEntry;
use App\Models\FailedLoginTally; use App\Models\FailedLoginTally;
use App\Models\PressReleaseAttachment; use App\Models\PressReleaseAttachment;
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive; use App\Services\Archive\EvidenceArchive;
use App\Services\Monitoring\HealthProbe; use App\Services\Monitoring\HealthProbe;
use App\Services\PressRelease\Images\PressReleaseImageRemover; use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; 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.', : '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 // Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert // der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben. // 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);
}
}

View file

@ -23,17 +23,19 @@ class ContentSecurityPolicy
$own = array_merge(["'self'"], self::ownOrigins()); $own = array_merge(["'self'"], self::ownOrigins());
$umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null; $umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null;
$errors = self::originOf((string) config('monitoring.browser.dsn')); $errors = self::originOf((string) config('monitoring.browser.dsn'));
// Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe.
$turnstile = 'https://challenges.cloudflare.com';
$directives = [ $directives = [
'default-src' => ["'self'"], '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'"], 'style-src' => [...$own, "'unsafe-inline'"],
'img-src' => [...$own, 'data:', 'blob:', 'https:'], 'img-src' => [...$own, 'data:', 'blob:', 'https:'],
'font-src' => [...$own, 'data:'], 'font-src' => [...$own, 'data:'],
'connect-src' => [...$own, $umami, $errors], 'connect-src' => [...$own, $umami, $errors],
'media-src' => [...$own], 'media-src' => [...$own],
'object-src' => ["'none'"], 'object-src' => ["'none'"],
'frame-src' => ["'self'"], 'frame-src' => ["'self'", $turnstile],
'frame-ancestors' => ["'self'"], 'frame-ancestors' => ["'self'"],
'base-uri' => ["'self'"], 'base-uri' => ["'self'"],
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'], 'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],

View file

@ -147,4 +147,21 @@ return [
'password' => env('BASIC_AUTH_PASSWORD'), '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,
],
]; ];

View file

@ -104,6 +104,8 @@ return [
| monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene | monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene
| Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces | Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces
| market Kursdaten-Abruf (market:refresh) | 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) | admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte)
| |
*/ */
@ -119,6 +121,10 @@ return [
// CSP-Meldungen der Browser (Melde-Modus, CspReportController). // CSP-Meldungen der Browser (Melde-Modus, CspReportController).
'csp' => $topicChannel('csp'), 'csp' => $topicChannel('csp'),
// Spamschutz: abgewiesene und ungeprüfte Formulare, nur Grund und
// Netz-Präfix (TurnstileVerifier, ContactController).
'spam' => $topicChannel('spam'),
'admin_slow' => [ 'admin_slow' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/admin-slow.log'), 'path' => storage_path('logs/admin-slow.log'),

View file

@ -64,4 +64,19 @@ return [
'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false), 'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false),
'identify_users' => env('UMAMI_IDENTIFY_USERS', 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),
],
]; ];

View file

@ -224,6 +224,8 @@ return [
'validation_message' => 'Bitte geben Sie eine Nachricht ein.', 'validation_message' => 'Bitte geben Sie eine Nachricht ein.',
'validation_message_min' => 'Ihre Nachricht sollte mindestens :min Zeichen lang sein.', 'validation_message_min' => 'Ihre Nachricht sollte mindestens :min Zeichen lang sein.',
'validation_message_max' => 'Ihre Nachricht darf höchstens :max 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' => [ 'search' => [

View file

@ -219,6 +219,8 @@ return [
'validation_message' => 'Please enter a message.', 'validation_message' => 'Please enter a message.',
'validation_message_min' => 'Your message should be at least :min characters long.', 'validation_message_min' => 'Your message should be at least :min characters long.',
'validation_message_max' => 'Your message must not exceed :max characters.', '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' => [ 'search' => [

View file

@ -52,6 +52,8 @@
<env name="TRUSTED_PROXIES" value="" force="true"/> <env name="TRUSTED_PROXIES" value="" force="true"/>
<env name="SECURITY_HSTS" value="false" force="true"/> <env name="SECURITY_HSTS" value="false" force="true"/>
<env name="SECURITY_CSP_REPORT_ONLY" value="false" force="true"/> <env name="SECURITY_CSP_REPORT_ONLY" value="false" force="true"/>
<env name="TURNSTILE_SITE_KEY" value="" force="true"/>
<env name="TURNSTILE_SECRET_KEY" value="" force="true"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>

View file

@ -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())
<div wire:ignore
x-data="{
config: @js([
'sitekey' => (string) config('services.turnstile.site_key'),
'action' => $action,
'language' => app()->getLocale() === 'en' ? 'en' : 'de',
'wireToken' => $wireToken,
'wireUnavailable' => $wireUnavailable,
]),
token: '',
unavailable: false,
waiting: false,
widgetId: null,
init() {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.load();
}
});
observer.observe(this.$el);
const form = this.$el.closest('form');
if (form && ! this.config.wireToken) {
form.addEventListener('submit', (event) => {
if (this.token || this.unavailable) {
return;
}
event.preventDefault();
this.waiting = true;
this.load();
});
}
},
load() {
if (this.widgetId !== null || this.unavailable) {
return;
}
window.pkTurnstileReady ??= new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 10000);
window.pkTurnstileOnload = () => { clearTimeout(timer); resolve(window.turnstile); };
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=pkTurnstileOnload';
script.async = true;
script.onerror = () => { clearTimeout(timer); reject(new Error('load')); };
document.head.appendChild(script);
});
window.pkTurnstileReady.then((turnstile) => {
if (this.widgetId !== null) {
return;
}
this.widgetId = turnstile.render(this.$refs.box, {
sitekey: this.config.sitekey,
action: this.config.action,
language: this.config.language,
size: 'flexible',
'refresh-expired': 'auto',
callback: (token) => this.setToken(token),
'expired-callback': () => this.setToken(''),
'error-callback': (code) => {
if (! /^(300|600)/.test(String(code))) {
this.markUnavailable();
}
},
});
}).catch(() => {
window.pkTurnstileReady = null;
this.markUnavailable();
});
},
setToken(token) {
this.token = token;
if (this.config.wireToken) {
this.$wire.$set(this.config.wireToken, token, false);
}
if (token) {
this.submitPending();
}
},
markUnavailable() {
this.unavailable = true;
if (this.config.wireUnavailable) {
this.$wire.$set(this.config.wireUnavailable, true, false);
}
this.submitPending();
},
submitPending() {
if (! this.waiting) {
return;
}
this.waiting = false;
this.$nextTick(() => this.$el.closest('form')?.requestSubmit());
},
reset() {
this.setToken('');
if (this.widgetId !== null && window.turnstile) {
window.turnstile.reset(this.widgetId);
}
},
}"
@turnstile-reset.window="reset()"
{{ $attributes->class('min-h-[65px]') }}
>
<div x-ref="box"></div>
<input type="hidden" name="{{ \App\Services\Spam\TurnstileVerifier::UNAVAILABLE_FIELD }}" :value="unavailable ? '1' : ''" />
<p x-show="waiting" x-cloak class="mt-1 text-[11.5px] text-ink-3">{{ __('web.launcher.bot_check_waiting') }}</p>
</div>
@endif

View file

@ -163,6 +163,15 @@
<a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a> <a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a>
</p> </p>
{{-- Spamschutz für Gäste und unbestätigte Konten (siehe ContactController). --}}
@unless (auth()->user()?->hasVerifiedEmail())
<x-turnstile action="contact" />
@error('turnstile')
<p class="text-[11.5px] text-red-600 m-0">{{ $message }}</p>
@enderror
<input type="hidden" name="{{ \App\Services\Spam\ContactSpamFilter::STARTED_FIELD }}" value="{{ \App\Services\Spam\ContactSpamFilter::startedToken() }}" />
@endunless
{{-- Bot-Schutz: versteckte Honeypot-Felder (siehe ContactController). --}} {{-- Bot-Schutz: versteckte Honeypot-Felder (siehe ContactController). --}}
<div class="hidden" aria-hidden="true"> <div class="hidden" aria-hidden="true">
<label for="contact-launcher-website">Website</label> <label for="contact-launcher-website">Website</label>

View file

@ -19,13 +19,41 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
#[Url(as: 'topic', except: 'all')] #[Url(as: 'topic', except: 'all')]
public string $topicFilter = 'all'; public string $topicFilter = 'all';
/** @var list<string> Auswahl für die Sammelaktion „Als Spam markieren“. */
public array $selected = [];
public string $notification = '';
public function updatedStatusFilter(): void public function updatedStatusFilter(): void
{ {
$this->selected = [];
$this->resetPage(); $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 public function updatedTopicFilter(): void
{ {
$this->selected = [];
$this->resetPage(); $this->resetPage();
} }
@ -42,7 +70,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
'messages' => $messages, 'messages' => $messages,
'topicOptions' => ContactMessageTopic::cases(), 'topicOptions' => ContactMessageTopic::cases(),
'openCount' => ContactMessage::query()->open()->count(), '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::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok', ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err', ContactMessageStatus::Rejected => 'err',
ContactMessageStatus::Spam => 'muted',
}; };
} }
}; ?> }; ?>
@ -67,14 +97,18 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
/> />
{{-- ============== KPI-Reihe ============== --}} {{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)"> <x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta> <x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend> <x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
</x-portal.stat-card> </x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)"> <x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta> <x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend> <x-slot:trend>{{ __('inkl. erledigt, ohne Spam') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Spam')" :value="number_format($spamCount)">
<x-slot:meta>{{ __('in Quarantäne') }}</x-slot:meta>
<x-slot:trend>{{ __('ohne Mail, nach :days Tagen gelöscht', ['days' => \App\Models\ContactMessage::SPAM_RETENTION_DAYS]) }}</x-slot:trend>
</x-portal.stat-card> </x-portal.stat-card>
</section> </section>
@ -89,6 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<option value="all">{{ __('Alle Status') }}</option> <option value="all">{{ __('Alle Status') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option> <option value="resolved">{{ __('Erledigt') }}</option>
<option value="rejected">{{ __('Abgelehnt') }}</option> <option value="rejected">{{ __('Abgelehnt') }}</option>
<option value="spam">{{ __('Spam (Quarantäne)') }}</option>
</flux:select> </flux:select>
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56"> <flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
@ -104,12 +139,28 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<article class="panel overflow-hidden"> <article class="panel overflow-hidden">
<div class="panel-head"> <div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span> <span class="section-eyebrow">{{ __('Anliegen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]"> <div class="flex items-center gap-3">
{{ __(':count Einträge', ['count' => $messages->total()]) }} @if (count($selected) > 0)
</span> <flux:button size="sm" variant="danger" icon="no-symbol" wire:click="markSelectedAsSpam"
wire:confirm="{{ __('Ausgewählte Anliegen als Spam markieren? Es geht keine Mail raus, nach 30 Tagen werden sie gelöscht.') }}">
{{ __(':count als Spam markieren', ['count' => count($selected)]) }}
</flux:button>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $messages->total()]) }}
</span>
</div>
</div> </div>
@if ($notification)
<div class="mx-5 mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ $notification }}
</div>
@endif
<flux:table> <flux:table>
<flux:table.columns> <flux:table.columns>
<flux:table.column class="w-8"><span class="sr-only">{{ __('Auswahl') }}</span></flux:table.column>
<flux:table.column>{{ __('Anliegen') }}</flux:table.column> <flux:table.column>{{ __('Anliegen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column> <flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Absender') }}</flux:table.column> <flux:table.column>{{ __('Absender') }}</flux:table.column>
@ -121,6 +172,13 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<flux:table.rows> <flux:table.rows>
@forelse ($messages as $message) @forelse ($messages as $message)
<flux:table.row :key="$message->id"> <flux:table.row :key="$message->id">
<flux:table.cell>
@unless ($message->isSpam())
<input type="checkbox" wire:model.live="selected" value="{{ $message->id }}"
aria-label="{{ __('Anliegen :ref auswählen', ['ref' => $message->reference()]) }}"
class="size-4 cursor-pointer accent-[color:var(--color-hub)]" />
@endunless
</flux:table.cell>
<flux:table.cell> <flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]"> <span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $message->topic->label() }} {{ $message->topic->label() }}
@ -133,6 +191,11 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}"> <span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
{{ $message->status->label() }} {{ $message->status->label() }}
</span> </span>
@if ($message->isSpam() && $message->spamReasonLabel())
<span class="block mt-1 text-[11px] text-[color:var(--color-ink-3)]">{{ $message->spamReasonLabel() }}</span>
@elseif ($message->skippedBotCheck())
<span class="badge warn mt-1">{{ __('ohne Bot-Prüfung') }}</span>
@endif
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]"> <span class="text-[12.5px] text-[color:var(--color-ink)]">
@ -161,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
</flux:table.row> </flux:table.row>
@empty @empty
<flux:table.row> <flux:table.row>
<flux:table.cell colspan="6"> <flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center"> <div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3 <div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]"> bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">

View file

@ -2,6 +2,7 @@
use App\Enums\ContactMessageStatus; use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage; use App\Models\ContactMessage;
use App\Services\Web\ContactMessageNotifier;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
@ -56,6 +57,34 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
$this->notification = __('Anliegen wieder geöffnet.'); $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 private function finish(ContactMessageStatus $status, string $notification): void
{ {
$this->message()->update([ $this->message()->update([
@ -85,6 +114,7 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
ContactMessageStatus::InProgress => 'hub', ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok', ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err', ContactMessageStatus::Rejected => 'err',
ContactMessageStatus::Spam => 'muted',
}; };
} }
}; ?> }; ?>
@ -155,6 +185,18 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt> <dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd> <dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd>
</div> </div>
@if ($message->spamReasonLabel())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Spamschutz') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->spamReasonLabel() }}</dd>
</div>
@endif
@if ($message->skippedBotCheck())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Bot-Prüfung') }}</dt>
<dd><span class="badge warn">{{ __('Cloudflare nicht erreichbar — bitte prüfen') }}</span></dd>
</div>
@endif
@if ($message->user) @if ($message->user)
<div> <div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt> <dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt>
@ -217,6 +259,13 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
<flux:button variant="danger" icon="x-mark" wire:click="reject"> <flux:button variant="danger" icon="x-mark" wire:click="reject">
{{ __('Ablehnen') }} {{ __('Ablehnen') }}
</flux:button> </flux:button>
<flux:button variant="ghost" icon="no-symbol" wire:click="markAsSpam">
{{ __('Als Spam markieren') }}
</flux:button>
@elseif ($message->isSpam())
<flux:button variant="primary" icon="arrow-uturn-left" wire:click="releaseFromSpam">
{{ __('Kein Spam — wieder öffnen') }}
</flux:button>
@else @else
<flux:button variant="filled" icon="arrow-path" wire:click="reopen"> <flux:button variant="filled" icon="arrow-path" wire:click="reopen">
{{ __('Wieder öffnen') }} {{ __('Wieder öffnen') }}

View file

@ -1,7 +1,11 @@
<?php <?php
use App\Enums\ActivityType;
use App\Enums\RegistrationType; use App\Enums\RegistrationType;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Spam\GibberishDetector;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -22,13 +26,24 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
public bool $terms_accepted = false; public bool $terms_accepted = false;
/** Vom Turnstile-Widget gesetzt (Spamschutz B4). */
public string $turnstileToken = '';
public bool $turnstileUnavailable = false;
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
*/ */
public function register(): void public function register(TurnstileVerifier $turnstile, ActivityLog $activityLog): void
{ {
$validated = $this->validate([ $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], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
'terms_accepted' => ['accepted'], 'terms_accepted' => ['accepted'],
@ -46,6 +61,21 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
RateLimiter::hit('register-ip|'.request()->ip(), 3600); 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 // Die bestätigte AGB-/Datenschutz-Checkbox wird als DSGVO-Einwilligung
// mit Zeitstempel festgehalten (users.gdpr_consent_at). // mit Zeitstempel festgehalten (users.gdpr_consent_at).
unset($validated['terms_accepted']); unset($validated['terms_accepted']);
@ -65,6 +95,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
// SendEmailVerificationNotification-Listener die Bestätigungsmail. // SendEmailVerificationNotification-Listener die Bestätigungsmail.
event(new Registered($user = User::create($validated))); 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); Auth::login($user);
// Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das // 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 @enderror
</div> </div>
<div>
<x-turnstile action="register" wire-token="turnstileToken" wire-unavailable="turnstileUnavailable" />
@error('turnstile')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="register"> <button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="register">
<span wire:loading.remove wire:target="register">{{ __('Konto erstellen') }}</span> <span wire:loading.remove wire:target="register">{{ __('Konto erstellen') }}</span>
<span wire:loading wire:target="register">{{ __('Konto wird angelegt …') }}</span> <span wire:loading wire:target="register">{{ __('Konto wird angelegt …') }}</span>

View file

@ -207,6 +207,49 @@
Aufbewahrungspflichten oder berechtigten Interessen an einer weiteren Speicherung Aufbewahrungspflichten oder berechtigten Interessen an einer weiteren Speicherung
entgegenstehen. entgegenstehen.
</p> </p>
<p>
Anfragen, die unser Spamfilter als unerwünschte Werbung oder automatisierte Eingabe
erkennt, werden ohne Weiterleitung an unser Team zurückgehalten, damit wir Fehleinstufungen
prüfen können, und spätestens nach 30 Tagen automatisch gelöscht.
</p>
{{-- Spamschutz mit Cloudflare Turnstile (ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md), Vorschlag zur juristischen Freigabe. --}}
<h2>6a. Schutz vor automatisierten Anfragen (Cloudflare Turnstile)</h2>
<p>
Um unsere Formulare vor automatisierten Eingaben (Spam, Bots) zu schützen, setzen wir im
Kontaktformular und bei der Registrierung den Dienst Turnstile ein. Anbieter ist die
Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA.
</p>
<p>
Turnstile wird erst geladen, wenn Sie das Kontaktformular öffnen oder die
Registrierungsseite aufrufen. Der Dienst prüft anhand technischer Merkmale, ob die Eingabe
von einem Menschen stammt; in der Regel ohne dass Sie etwas tun müssen. Dabei werden
insbesondere verarbeitet:
</p>
<ul>
<li>IP-Adresse,</li>
<li>Informationen zu Browser und Betriebssystem (z. B. User-Agent, Spracheinstellungen),</li>
<li>Merkmale des Seitenaufrufs und der Interaktion mit der Prüfung,</li>
<li>Zeitpunkt der Prüfung.</li>
</ul>
<p>
Turnstile setzt nach Angaben von Cloudflare keine Cookies zu Werbe- oder Trackingzwecken
und nutzt die Daten nicht für personalisierte Werbung. Wir erhalten von Cloudflare nur
das Ergebnis der Prüfung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser
berechtigtes Interesse liegt darin, Missbrauch unserer Formulare zu verhindern, etwa den
massenhaften Versand von Bestätigungs-E-Mails an fremde Adressen. Für den Zugriff auf
Informationen in Ihrem Endgerät ist Rechtsgrundlage § 25 Abs. 2 Nr. 2 TDDDG, da die
Prüfung für den sicheren Betrieb des von Ihnen genutzten Formulars unbedingt
erforderlich ist.
</p>
<p>
Cloudflare kann Daten auch in den USA verarbeiten. Cloudflare ist nach dem
EU-US Data Privacy Framework zertifiziert; die Übermittlung erfolgt auf Grundlage des
Angemessenheitsbeschlusses der Europäischen Kommission (Art. 45 DSGVO). Weitere
Informationen finden Sie in den Datenschutzhinweisen von Cloudflare
(<a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener">cloudflare.com/privacypolicy</a>)
und zu Turnstile (<a href="https://www.cloudflare.com/turnstile-privacy-policy/" target="_blank" rel="noopener">cloudflare.com/turnstile-privacy-policy</a>).
</p>
@if ($isHub) @if ($isHub)
<h2>7. Kundenkonto und Registrierung auf dem Hub</h2> <h2>7. Kundenkonto und Registrierung auf dem Hub</h2>

View file

@ -11,6 +11,7 @@ use App\Console\Commands\ExpireGrandfathering;
use App\Console\Commands\FetchMailBounces; use App\Console\Commands\FetchMailBounces;
use App\Console\Commands\GenerateManualInvoices; use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\MonitoringHeartbeat; use App\Console\Commands\MonitoringHeartbeat;
use App\Console\Commands\PruneBotRegistrations;
use App\Console\Commands\PublishScheduledPressReleases; use App\Console\Commands\PublishScheduledPressReleases;
use App\Console\Commands\PurgeConversionEvents; use App\Console\Commands\PurgeConversionEvents;
use App\Console\Commands\PurgeExpiredPressReleaseDrafts; use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
@ -20,6 +21,7 @@ use App\Console\Commands\PurgeRedactionSnapshots;
use App\Console\Commands\RefreshMarketData; use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Console\Commands\SweepOrphanPressReleaseOriginals; use App\Console\Commands\SweepOrphanPressReleaseOriginals;
use App\Models\ContactMessage;
use App\Models\FailedLoginTally; use App\Models\FailedLoginTally;
use App\Support\OperationalAlert; use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event; use Illuminate\Console\Scheduling\Event;
@ -70,6 +72,16 @@ Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->runInBackground() ->runInBackground()
->onFailure($alertOnFailure('mail:check-domains')); ->onFailure($alertOnFailure('mail:check-domains'));
// Bot-Registrierungen löschen (Spamschutz B4, täglich 03:45): unbestätigt,
// nie genutzt, älter als 14 Tage. Aus, bis der Probelauf auf Live geprüft
// ist (USERS_PRUNE_BOT_REGISTRATIONS=true).
Schedule::command(PruneBotRegistrations::class)
->dailyAt('03:45')
->when(fn (): bool => (bool) config('auth.prune_bot_registrations.enabled'))
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('users:prune-bot-registrations'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00) // Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30']) Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00') ->dailyAt('03:00')
@ -247,12 +259,13 @@ Schedule::command(MonitoringHeartbeat::class)
->onFailure($alertOnFailure('monitoring:heartbeat')); ->onFailure($alertOnFailure('monitoring:heartbeat'));
// Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen // Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen
// (täglich 04:05, config monitoring.failed_logins.retention_days). // (täglich 04:05, config monitoring.failed_logins.retention_days) und
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class]]) // Spam-Quarantäne des Kontaktformulars nach 30 Tagen (Spamschutz B3).
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class, ContactMessage::class]])
->dailyAt('04:05') ->dailyAt('04:05')
->withoutOverlapping(30) ->withoutOverlapping(30)
->runInBackground() ->runInBackground()
->onFailure($alertOnFailure('model:prune failed_login_tallies')); ->onFailure($alertOnFailure('model:prune failed_login_tallies, contact_messages'));
// Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin // Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin
// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar. // sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar.

View file

@ -2,9 +2,11 @@
use App\Enums\ContactMessageStatus; use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic; use App\Enums\ContactMessageTopic;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage; use App\Models\ContactMessage;
use App\Models\User; use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@ -99,3 +101,103 @@ test('customers cannot access the contact queue', function () {
->get('https://pressekonto.test/admin/contact-messages') ->get('https://pressekonto.test/admin/contact-messages')
->assertForbidden(); ->assertForbidden();
}); });
test('quarantined spam is hidden from the default view and listed under the spam filter', function () {
$open = ContactMessage::factory()->create(['name' => 'Echte Anfrage']);
$spam = ContactMessage::factory()->create([
'name' => 'HarryCoidS',
'status' => ContactMessageStatus::Spam,
'payload' => ['spam_reason' => 'link'],
]);
Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.index')
->assertSee($open->reference())
->assertDontSee($spam->reference())
->set('statusFilter', 'spam')
->assertSee($spam->reference())
->assertSee('Link im Text')
->assertDontSee($open->reference());
});
test('an admin can move selected messages into the spam quarantine', function () {
$first = ContactMessage::factory()->create();
$second = ContactMessage::factory()->create();
$kept = ContactMessage::factory()->create();
Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.index')
->set('selected', [(string) $first->id, (string) $second->id])
->call('markSelectedAsSpam')
->assertSet('selected', [])
->assertSee('2 Anliegen als Spam markiert.');
expect($first->refresh()->status)->toBe(ContactMessageStatus::Spam)
->and($first->payload['spam_reason'])->toBe('manual')
->and($second->refresh()->status)->toBe(ContactMessageStatus::Spam)
->and($kept->refresh()->status)->toBe(ContactMessageStatus::Open);
});
test('releasing a filtered message reopens it and sends the held-back mail once', function () {
Mail::fake();
config()->set('domains.domains.presseecho.brand.contact_email', 'redaktion@presseecho.de');
$message = ContactMessage::factory()->create([
'portal' => 'presseecho',
'status' => ContactMessageStatus::Spam,
'payload' => ['spam_reason' => 'link'],
'page_url' => 'https://presseecho.test/de/faq',
]);
$component = Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.show', ['id' => $message->id])
->call('releaseFromSpam')
->assertSee('Mail an das Team wurde nachgeholt');
expect($message->refresh()->status)->toBe(ContactMessageStatus::Open);
Mail::assertQueued(
ContactRequestSubmitted::class,
fn (ContactRequestSubmitted $mail) => $mail->hasTo('redaktion@presseecho.de') && $mail->host === 'presseecho.test',
);
// Erneut als Spam markiert und wieder zurückgeholt: keine zweite Mail.
$component->call('markAsSpam')->call('releaseFromSpam');
Mail::assertQueuedCount(1);
});
test('releasing manually marked spam sends no mail — it already had one', function () {
Mail::fake();
$message = ContactMessage::factory()->create();
Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.show', ['id' => $message->id])
->call('markAsSpam')
->call('releaseFromSpam');
expect($message->refresh()->status)->toBe(ContactMessageStatus::Open);
Mail::assertNothingOutgoing();
});
test('messages accepted without bot check are flagged for review', function () {
$message = ContactMessage::factory()->create(['payload' => ['bot_check' => 'unavailable', 'bot_check_reason' => 'siteverify_unreachable']]);
Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.index')
->assertSee('ohne Bot-Prüfung');
Livewire::actingAs(contactMessagesAdmin())
->test('admin.contact-messages.show', ['id' => $message->id])
->assertSee('Cloudflare nicht erreichbar');
});
test('customers cannot use the spam actions', function () {
$message = ContactMessage::factory()->create();
$customer = User::factory()->create(['is_active' => true]);
Livewire::actingAs($customer)
->test('admin.contact-messages.index')
->set('selected', [(string) $message->id])
->call('markSelectedAsSpam')
->assertForbidden();
expect($message->refresh()->status)->toBe(ContactMessageStatus::Open);
});

View file

@ -1,9 +1,11 @@
<?php <?php
use App\Enums\ActivityType;
use App\Enums\Portal; use App\Enums\Portal;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\PressReleaseImage; use App\Models\PressReleaseImage;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Admin\AdminSystemHealth; use App\Services\Admin\AdminSystemHealth;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -149,3 +151,31 @@ test('snapshot reports imagick as ok when it is loaded', function () {
expect($checks['imagick']['status'])->toBe(class_exists(Imagick::class) ? 'ok' : 'warn'); expect($checks['imagick']['status'])->toBe(class_exists(Imagick::class) ? 'ok' : 'warn');
}); });
test('snapshot warns in production when the turnstile keys are missing', function () {
app()->instance('env', 'production');
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks['turnstile']['status'])->toBe('warn')
->and($checks['turnstile']['detail'])->toContain('TURNSTILE_SITE_KEY');
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecret');
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks['turnstile']['status'])->toBe('ok');
});
test('snapshot asks to review forms accepted without bot check', function () {
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks['bot_check_unavailable']['status'])->toBe('ok');
app(ActivityLog::class)->record(ActivityType::BotCheckUnavailable, 'Kontaktanfrage KT-1 ohne Bot-Prüfung angenommen.');
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks['bot_check_unavailable']['status'])->toBe('warn')
->and($checks['bot_check_unavailable']['detail'])->toContain('1 Formular(e)');
});

View file

@ -0,0 +1,89 @@
<?php
use App\Models\Company;
use App\Models\User;
use Illuminate\Console\Scheduling\Schedule;
use Tests\TestCase;
/*
* users:prune-bot-registrations (Spamschutz B4): nur Formular-Konten, die
* nie bestätigt und nie genutzt wurden — Altkonten bleiben unberührt.
*/
/**
* @param array<string, mixed> $attributes
*/
function botRegistration(array $attributes = []): User
{
return User::factory()->unverified()->create([
'name' => 'ruLrlotwaCoVStmZetSxD',
'is_active' => false,
'legacy_id' => null,
'last_login_at' => null,
'created_at' => now()->subDays(20),
...$attributes,
]);
}
test('unused unconfirmed registrations older than the deadline are deleted', function () {
/** @var TestCase $this */
$bot = botRegistration();
$this->artisan('users:prune-bot-registrations')
->expectsOutputToContain('Bot-Registrierungen gelöscht: 1')
->assertSuccessful();
expect(User::withTrashed()->find($bot->id))->toBeNull();
});
test('accounts that are recent, legacy, confirmed, used or owned are kept', function () {
/** @var TestCase $this */
$kept = [
botRegistration(['created_at' => now()->subDays(5)]),
botRegistration(['legacy_id' => 4711]),
botRegistration(['email_verified_at' => now()]),
botRegistration(['last_login_at' => now()->subDays(3)]),
botRegistration(['oauth_provider' => 'google']),
botRegistration(['is_active' => true]),
];
$withCompany = botRegistration();
$withCompany->companies()->attach(Company::factory()->create(), ['role' => 'owner', 'granted_via' => 'manual']);
$kept[] = $withCompany;
$this->artisan('users:prune-bot-registrations')->assertSuccessful();
expect(User::query()->whereKey(collect($kept)->pluck('id'))->count())->toBe(count($kept));
});
test('the dry run lists candidates and deletes nothing', function () {
/** @var TestCase $this */
$bot = botRegistration(['email' => 'opfer@example.test']);
$this->artisan('users:prune-bot-registrations', ['--dry-run' => true])
->expectsOutputToContain('op***@example.test')
->expectsOutputToContain('Probelauf: 1 Konten älter als 14 Tage würden gelöscht, davon 1 mit Zufallsnamen.')
->assertSuccessful();
expect(User::query()->find($bot->id))->not->toBeNull();
});
test('a deadline below seven days is refused', function () {
/** @var TestCase $this */
botRegistration(['created_at' => now()->subDays(3)]);
$this->artisan('users:prune-bot-registrations', ['--days' => 2])->assertFailed();
expect(User::query()->count())->toBe(1);
});
test('the daily run stays off until it is switched on', function () {
$event = collect(app(Schedule::class)->events())
->first(fn ($event) => str_contains((string) $event->command, 'users:prune-bot-registrations'));
expect($event)->not->toBeNull()
->and($event->filtersPass(app()))->toBeFalse();
config()->set('auth.prune_bot_registrations.enabled', true);
expect($event->filtersPass(app()))->toBeTrue();
});

View file

@ -0,0 +1,132 @@
<?php
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\TestCase;
/*
* Spamschutz der Registrierung (Spamschutz B4): 389 Bot-Konten in 30 Tagen
* bis 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
*/
function enableRegistrationTurnstile(): void
{
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests');
config()->set('services.turnstile.fail_open', true);
}
function registrationForm(string $name = 'Paula Presse'): Testable
{
return Livewire::test('auth.register')
->set('name', $name)
->set('email', 'paula@example.com')
->set('password', 'sicheres-passwort-2026')
->set('password_confirmation', 'sicheres-passwort-2026')
->set('terms_accepted', true);
}
test('registration with a confirmed turnstile token creates the account', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::response(['success' => true, 'action' => 'register'])]);
registrationForm()
->set('turnstileToken', 'token-aus-dem-widget')
->call('register')
->assertHasNoErrors()
->assertRedirect(route('verification.notice', absolute: false));
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeTrue()
->and(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->count())->toBe(0);
});
test('registration without a confirmed token creates no account and sends no mail', function (string $token, array $siteverify) {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::response($siteverify)]);
registrationForm()
->set('turnstileToken', $token)
->call('register')
->assertHasErrors('turnstile')
->assertDispatched('turnstile-reset')
->assertSet('turnstileToken', '');
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeFalse();
Notification::assertNothingSent();
})->with([
'no token' => ['', ['success' => true, 'action' => 'register']],
'rejected token' => ['token', ['success' => false, 'error-codes' => ['invalid-input-response']]],
'token for the contact form' => ['token', ['success' => true, 'action' => 'contact']],
]);
test('if cloudflare is unreachable the account is created and marked for review', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
registrationForm()
->set('turnstileToken', 'token-aus-dem-widget')
->call('register')
->assertHasNoErrors();
$user = User::query()->where('email', 'paula@example.com')->sole();
$event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole();
expect($event->user_id)->toBe($user->id)
->and($event->meta)->toMatchArray(['form' => 'register', 'reason' => 'siteverify_unreachable']);
});
test('if the widget could not load in the browser the account is created and marked', function () {
/** @var TestCase $this */
Notification::fake();
enableRegistrationTurnstile();
Http::fake();
registrationForm()
->set('turnstileUnavailable', true)
->call('register')
->assertHasNoErrors();
Http::assertNothingSent();
expect(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole()->meta['reason'])
->toBe('widget_not_loaded');
});
test('random bot names are rejected', function (string $name) {
/** @var TestCase $this */
Notification::fake();
registrationForm($name)->call('register')->assertHasErrors('name');
expect(User::query()->count())->toBe(0);
})->with(['ruLrlotwaCoVStmZetSxD', 'VJxlVxkqQzgMfJPaM', 'GeCWsvfRZNHeNXubB']);
test('real names pass the name check', function (string $name) {
/** @var TestCase $this */
Notification::fake();
registrationForm($name)->call('register')->assertHasNoErrors();
})->with(['Paula Presse', 'Anna-Lena Müller-Lüdenscheidt', 'Dr. Jabena B. McNair', 'MacGregor', 'Sky']);
test('the registration page renders the widget only with keys', function () {
/** @var TestCase $this */
// Ohne Keys kein Widget (der Livewire-Snapshot nennt die Properties trotzdem).
$this->get('/register')->assertSuccessful()->assertDontSee('pkTurnstileReady', false);
enableRegistrationTurnstile();
$this->get('/register')
->assertSuccessful()
->assertSee('0x4AAAAAAAsitekey', false)
->assertSee('pkTurnstileReady', false);
});

View file

@ -28,6 +28,7 @@ test('every topic channel writes its own daily file with the shared retention',
'monitoring' => ['monitoring', 'monitoring'], 'monitoring' => ['monitoring', 'monitoring'],
'market' => ['market', 'market'], 'market' => ['market', 'market'],
'csp' => ['csp', 'csp'], 'csp' => ['csp', 'csp'],
'spam' => ['spam', 'spam'],
'admin_slow' => ['admin_slow', 'admin-slow'], 'admin_slow' => ['admin_slow', 'admin-slow'],
]); ]);

View file

@ -0,0 +1,287 @@
<?php
use App\Enums\ActivityType;
use App\Enums\ContactMessageStatus;
use App\Mail\ContactRequestSubmitted;
use App\Models\ActivityEvent;
use App\Models\ContactMessage;
use App\Models\User;
use App\Services\Spam\ContactSpamFilter;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\TestCase;
/*
* Spamschutz des Kontaktformulars (dev/sicherheit/ENTWICKLUNGSPLAN-
* SPAMSCHUTZ-2026-10-09.md): Turnstile für Gäste, Inhaltsfilter mit
* Quarantäne, Durchlassen und Markieren bei nicht erreichbarem Cloudflare.
*/
function enableTurnstile(): void
{
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests');
config()->set('services.turnstile.fail_open', true);
}
/**
* @param array<string, mixed> $body
*/
function fakeSiteverify(array $body): void
{
Http::fake(['challenges.cloudflare.com/*' => Http::response($body)]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function contactPayload(array $overrides = []): array
{
return [
'name' => 'Paula Presse',
'email' => 'paula@example.test',
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
'cf-turnstile-response' => 'token-aus-dem-widget',
...$overrides,
];
}
test('a guest request with a confirmed turnstile token is accepted and mailed', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify(['success' => true, 'action' => 'contact', 'hostname' => 'businessportal24.test']);
$this->post('https://businessportal24.test/de/kontakt', contactPayload())
->assertSessionHasNoErrors()
->assertSessionHas('contact-status');
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Open)
->and($message->payload)->toBe(['bot_check' => 'passed']);
Mail::assertQueued(ContactRequestSubmitted::class);
Http::assertSent(fn (HttpRequest $request) => $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
&& $request['secret'] === '0x4AAAAAAAsecretkeyforthetests'
&& $request['response'] === 'token-aus-dem-widget'
&& $request['remoteip'] === '127.0.0.1');
});
test('a guest request without a confirmed token is rejected with a visible error', function (array $overrides, ?array $siteverify) {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify($siteverify ?? ['success' => true, 'action' => 'contact']);
$this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides))
->assertSessionHasErrors(['turnstile' => __('web.launcher.bot_check_failed')]);
expect(ContactMessage::query()->count())->toBe(0);
Mail::assertNothingOutgoing();
})->with([
'no token' => [['cf-turnstile-response' => ''], null],
'token rejected by cloudflare' => [[], ['success' => false, 'error-codes' => ['invalid-input-response']]],
'token already used' => [[], ['success' => false, 'error-codes' => ['timeout-or-duplicate']]],
'token solved for another form' => [[], ['success' => true, 'action' => 'register']],
]);
test('if cloudflare is unreachable the request is accepted and marked for review', function (array $overrides, mixed $fake) {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake(['challenges.cloudflare.com/*' => $fake]);
$this->post('https://presseecho.test/de/kontakt', contactPayload($overrides))
->assertSessionHasNoErrors()
->assertSessionHas('contact-status');
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Open)
->and($message->skippedBotCheck())->toBeTrue();
Mail::assertQueued(ContactRequestSubmitted::class);
$event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole();
expect($event->subject_id)->toBe($message->id)
->and($event->description)->toContain($message->reference());
})->with([
'connection fails' => [[], fn () => Http::failedConnection()],
'siteverify answers 503' => [[], fn () => Http::response('', 503)],
'siteverify internal error' => [[], fn () => Http::response(['success' => false, 'error-codes' => ['internal-error']])],
'widget could not load in the browser' => [['cf-turnstile-response' => '', 'turnstile_unavailable' => '1'], fn () => Http::response(['success' => false])],
]);
test('a rejected secret key is our fault: the request passes, marked, and the error is logged', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
fakeSiteverify(['success' => false, 'error-codes' => ['invalid-input-secret']]);
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->skippedBotCheck())->toBeTrue();
Log::shouldHaveReceived('error')->withArgs(fn (string $message) => str_contains($message, 'TURNSTILE_SECRET_KEY'));
});
test('with fail_open disabled an unreachable cloudflare blocks the request', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
config()->set('services.turnstile.fail_open', false);
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
$this->post('https://presseecho.test/de/kontakt', contactPayload())->assertSessionHasErrors('turnstile');
expect(ContactMessage::query()->count())->toBe(0);
});
test('verified customers skip turnstile and the content filter', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake();
$this->actingAs(User::factory()->create())
->post('https://pressekonto.test/de/kontakt', contactPayload([
'cf-turnstile-response' => '',
'message' => 'Unsere Website https://beispiel-firma.de zeigt das falsche Logo, bitte prüfen.',
]))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
Http::assertNothingSent();
Mail::assertQueued(ContactRequestSubmitted::class);
});
test('unconfirmed accounts are checked like guests', function () {
/** @var TestCase $this */
Mail::fake();
enableTurnstile();
Http::fake();
$this->actingAs(User::factory()->unverified()->create())
->post('https://pressekonto.test/de/kontakt', contactPayload(['cf-turnstile-response' => '']))
->assertSessionHasErrors('turnstile');
});
test('spam from guests is quarantined silently: stored, no mail, same confirmation', function (array $overrides, string $reason) {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload($overrides))
->assertSessionHasNoErrors()
->assertSessionHas('contact-status', __('web.launcher.success'));
$message = ContactMessage::query()->sole();
expect($message->status)->toBe(ContactMessageStatus::Spam)
->and($message->payload['spam_reason'])->toBe($reason)
->and($message->status->isOpen())->toBeFalse();
Mail::assertNothingOutgoing();
expect(ActivityEvent::query()->where('type', ActivityType::ContactRequest->value)->count())->toBe(0);
})->with([
'link' => [['message' => 'Rolls-Royce Phantom for $15,000 https://telegra.ph/How-to-get-a-new-2026'], 'link'],
'bare domain with path' => [['message' => 'Mehr Besucher für Ihre Seite: indexhelper.live/angebot jetzt buchen'], 'link'],
'html link' => [['message' => '<a href=https://1sakhapresident.ru/>Президент</a> представляет'], 'link'],
'phrase plus page title' => [['message' => 'Ich wünsche weitere Informationen. Bitte kontaktieren Sie mich per E-Mail — aktuelle pressemitteilungen aus der deutschen wirtschaft.'], 'page_title'],
'random name' => [['name' => 'ruLrlotwaCoVStmZetSxD'], 'gibberish'],
'letter-digit block' => [['name' => 'NAEWTRER2720160NEYRTHYT', 'message' => 'METRYTRE2720160MAWRERGTRH'], 'gibberish'],
]);
test('a form submitted faster than a human can type is quarantined', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => ContactSpamFilter::startedToken()]))
->assertSessionHas('contact-status');
expect(ContactMessage::query()->sole()->payload['spam_reason'])->toBe('too_fast');
Mail::assertNothingOutgoing();
});
test('a link to one of our own pages is not spam, even next to the company website', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload([
'message' => 'Bitte korrigieren Sie im Newsroom die Website auf https://praxis-webseiten.com, '
.'betrifft https://businessportal24.test/de/pm/meine-meldung-123 — danke!',
]))->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
Mail::assertQueued(ContactRequestSubmitted::class);
});
test('a form filled in at human speed passes the time check', function () {
/** @var TestCase $this */
Mail::fake();
$started = ContactSpamFilter::startedToken();
$this->travel(20)->seconds();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => $started]))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
});
test('a forged time stamp is ignored rather than treated as spam', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/kontakt', contactPayload(['form_started' => 'manipuliert']))
->assertSessionHasNoErrors();
expect(ContactMessage::query()->sole()->status)->toBe(ContactMessageStatus::Open);
});
test('the launcher renders the turnstile widget and the time stamp for guests only', function () {
/** @var TestCase $this */
enableTurnstile();
$this->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertSee('0x4AAAAAAAsitekey', false)
->assertSee('name="turnstile_unavailable"', false)
->assertSee('name="form_started"', false);
$this->actingAs(User::factory()->create())
->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertDontSee('name="turnstile_unavailable"', false)
->assertDontSee('name="form_started"', false);
});
test('without keys no widget is rendered', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/faq')
->assertSuccessful()
->assertDontSee('name="turnstile_unavailable"', false);
});
test('quarantined spam is pruned after 30 days, everything else stays', function () {
/** @var TestCase $this */
$oldSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(31)]);
$freshSpam = ContactMessage::factory()->create(['status' => ContactMessageStatus::Spam, 'created_at' => now()->subDays(29)]);
$oldOpen = ContactMessage::factory()->create(['created_at' => now()->subDays(90)]);
$this->artisan('model:prune', ['--model' => [ContactMessage::class]])->assertSuccessful();
expect(ContactMessage::query()->pluck('id')->all())->toEqualCanonicalizing([$freshSpam->id, $oldOpen->id])
->and(ContactMessage::query()->find($oldSpam->id))->toBeNull();
});
test('the csp allows the turnstile script and challenge frame', function () {
/** @var TestCase $this */
config()->set('security.csp.report_only', true);
$policy = (string) $this->get('https://presseecho.test/de')->headers->get('Content-Security-Policy-Report-Only');
expect(Str::between($policy, 'script-src', ';'))->toContain('https://challenges.cloudflare.com')
->and(Str::between($policy, 'frame-src', ';'))->toContain('https://challenges.cloudflare.com');
});

View file

@ -0,0 +1,43 @@
<?php
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage;
use App\Models\User;
use Tests\TestCase;
/*
* contact-messages:quarantine-spam — einmalig für den Altbestand
* (Spamschutz, Entscheidung 2).
*/
test('open guest messages caught by the filter move into the quarantine', function () {
/** @var TestCase $this */
$spam = ContactMessage::factory()->create(['message' => 'WIN A 2026 ROLLS-ROYCE https://telegra.ph/win-123']);
$clean = ContactMessage::factory()->create(['message' => 'Guten Tag, ich habe eine Frage zu Ihren Preisen.']);
$customer = ContactMessage::factory()->create([
'message' => 'Unser Logo auf https://beispiel-firma.de ist veraltet.',
'user_id' => User::factory()->create()->id,
]);
$resolved = ContactMessage::factory()->resolved()->create(['message' => 'Alt: https://telegra.ph/alt']);
$this->artisan('contact-messages:quarantine-spam')
->expectsOutputToContain('1 Anliegen als Spam markiert.')
->assertSuccessful();
expect($spam->refresh()->status)->toBe(ContactMessageStatus::Spam)
->and($spam->payload['spam_reason'])->toBe('link')
->and($clean->refresh()->status)->toBe(ContactMessageStatus::Open)
->and($customer->refresh()->status)->toBe(ContactMessageStatus::Open)
->and($resolved->refresh()->status)->toBe(ContactMessageStatus::Resolved);
});
test('the dry run changes nothing', function () {
/** @var TestCase $this */
$spam = ContactMessage::factory()->create(['message' => 'WIN A 2026 ROLLS-ROYCE https://telegra.ph/win-123']);
$this->artisan('contact-messages:quarantine-spam', ['--dry-run' => true])
->expectsOutputToContain('Probelauf: 1 Anliegen würden als Spam markiert.')
->assertSuccessful();
expect($spam->refresh()->status)->toBe(ContactMessageStatus::Open);
});

View file

@ -74,7 +74,7 @@ abstract class TestCase extends BaseTestCase
{ {
$testing = config('logging.channels.testing'); $testing = config('logging.channels.testing');
foreach (['billing', 'ki', 'monitoring', 'market', 'csp'] as $channel) { foreach (['billing', 'ki', 'monitoring', 'market', 'csp', 'spam'] as $channel) {
config()->set("logging.channels.{$channel}", $testing); config()->set("logging.channels.{$channel}", $testing);
} }
} }

View file

@ -0,0 +1,21 @@
<?php
use App\Services\Spam\GibberishDetector;
test('bot names are recognised as random person names', function (string $name) {
expect(GibberishDetector::isRandomPersonName($name))->toBeTrue();
})->with(['ruLrlotwaCoVStmZetSxD', 'VBwauClhcuUvMvksdgWnjr', 'iOmGRYDQjETnnHmPKzjgGe']);
test('real person names are never random', function (string $name) {
expect(GibberishDetector::isRandomPersonName($name))->toBeFalse();
})->with(['Paula Presse', 'MacGregor', 'Anna-Lena Müller', 'McDonaldSmith', 'RobertHix', 'ABCDEFGHIJKLMN']);
test('the contact name check spares company names but catches bot tokens', function (string $value, bool $random) {
expect(GibberishDetector::isRandomToken($value))->toBe($random);
})->with([
'company with camel case' => ['StammZellenPowerTeam', false],
'company with caps' => ['KUUNSTPavillon', false],
'bot name' => ['ruLrlotwaCoVStmZetSxD', true],
'letter-digit block' => ['NAEWTRER2720160NEYRTHYT', true],
'normal sentence' => ['Guten Tag, ich habe eine Frage.', false],
]);