feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61f449f483
commit
06d2889a4d
40 changed files with 1992 additions and 40 deletions
12
.env.example
12
.env.example
|
|
@ -355,3 +355,15 @@ IMAGE_PRECHECK_STAGE=1
|
|||
# SECURITY_HSTS_MAX_AGE=31536000
|
||||
# CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log.
|
||||
# SECURITY_CSP_REPORT_ONLY=
|
||||
|
||||
# Spamschutz mit Cloudflare Turnstile (Kontakt-Widget, Registrierung). Ohne beide Keys aus.
|
||||
# Lokal die Testkeys von Cloudflare verwenden (1x00000000000000000000AA / 1x0000000000000000000000000000000AA),
|
||||
# die echten Keys sind an die Live-Hostnamen gebunden. Ist Cloudflare nicht erreichbar,
|
||||
# wird durchgelassen und markiert; TURNSTILE_FAIL_OPEN=false lehnt stattdessen ab.
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# TURNSTILE_TIMEOUT=3
|
||||
# TURNSTILE_FAIL_OPEN=true
|
||||
# Unbestätigte Bot-Registrierungen nach 14 Tagen löschen (users:prune-bot-registrations).
|
||||
# Erst einschalten, nachdem der Probelauf auf Live geprüft ist.
|
||||
# USERS_PRUNE_BOT_REGISTRATIONS=false
|
||||
|
|
|
|||
145
app/Console/Commands/PruneBotRegistrations.php
Normal file
145
app/Console/Commands/PruneBotRegistrations.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/QuarantineContactSpam.php
Normal file
60
app/Console/Commands/QuarantineContactSpam.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@ enum ActivityType: string
|
|||
|
||||
case TwoFactorFailed = 'two_factor_failed';
|
||||
|
||||
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
|
||||
case BotCheckUnavailable = 'bot_check_unavailable';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -73,6 +76,7 @@ enum ActivityType: string
|
|||
self::LoginFailed => 'Anmeldung fehlgeschlagen',
|
||||
self::LoginLockout => 'Anmeldesperre',
|
||||
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
||||
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +106,7 @@ enum ActivityType: string
|
|||
self::ManualInvoice => 'amber',
|
||||
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
||||
self::LoginLockout => 'red',
|
||||
self::BotCheckUnavailable => 'orange',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Enums/BotCheckStatus.php
Normal file
27
app/Enums/BotCheckStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ enum ContactMessageStatus: string
|
|||
case Resolved = 'resolved';
|
||||
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
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -16,6 +19,7 @@ enum ContactMessageStatus: string
|
|||
self::InProgress => 'In Bearbeitung',
|
||||
self::Resolved => 'Erledigt',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
self::Spam => 'Spam',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,21 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\BotCheckStatus;
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Spam\ContactSpamFilter;
|
||||
use App\Services\Spam\SpamLog;
|
||||
use App\Services\Spam\TurnstileVerifier;
|
||||
use App\Services\Web\ContactMessageNotifier;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException;
|
|||
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
||||
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
||||
* Absenderadresse der Anwendung).
|
||||
*
|
||||
* Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md):
|
||||
* Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters
|
||||
* landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht
|
||||
* erreichbar, wird angenommen und im Verlauf markiert.
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TurnstileVerifier $turnstile,
|
||||
private readonly ContactSpamFilter $spamFilter,
|
||||
private readonly ContactMessageNotifier $notifier,
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
|
|
@ -53,27 +72,66 @@ class ContactController extends Controller
|
|||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
// Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort
|
||||
// landen die Bot-Registrierungen) prüfen wir wie Gäste.
|
||||
$trusted = $request->user()?->hasVerifiedEmail() === true;
|
||||
|
||||
$botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact');
|
||||
|
||||
if ($botCheck?->blocks()) {
|
||||
throw ValidationException::withMessages([
|
||||
'turnstile' => __('web.launcher.bot_check_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$spamReason = $trusted ? null : $this->spamFilter->reasonFor(
|
||||
$validated['name'],
|
||||
$validated['message'],
|
||||
$request->string(ContactSpamFilter::STARTED_FIELD)->toString(),
|
||||
);
|
||||
|
||||
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
||||
|
||||
ContactMessage::create([
|
||||
$message = ContactMessage::create([
|
||||
'portal' => $this->portal($request),
|
||||
'topic' => $topic,
|
||||
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'message' => $validated['message'],
|
||||
'page_url' => $validated['page_url'] ?? null,
|
||||
'payload' => array_filter([
|
||||
'spam_reason' => $spamReason,
|
||||
'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value,
|
||||
'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null,
|
||||
]) ?: null,
|
||||
'requester_ip' => $request->ip(),
|
||||
'user_id' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
|
||||
senderName: $validated['name'],
|
||||
senderEmail: $validated['email'],
|
||||
messageText: $validated['message'],
|
||||
host: $request->getHost(),
|
||||
topicLabel: $topic->label(),
|
||||
pageUrl: $validated['page_url'] ?? null,
|
||||
))->afterCommit());
|
||||
// Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
|
||||
// bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
|
||||
if ($spamReason !== null) {
|
||||
Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
|
||||
'reason' => $spamReason,
|
||||
'portal' => $message->portal,
|
||||
'ip_prefix' => SpamLog::ipPrefix($request->ip()),
|
||||
]);
|
||||
|
||||
return $this->done();
|
||||
}
|
||||
|
||||
if ($botCheck?->needsReview()) {
|
||||
$this->activityLog->record(
|
||||
ActivityType::BotCheckUnavailable,
|
||||
sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason),
|
||||
subject: $message,
|
||||
actorLabel: (string) $message->email,
|
||||
meta: ['form' => 'contact', 'reason' => $botCheck->reason],
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifier->notify($message, $request->getHost());
|
||||
|
||||
return $this->done();
|
||||
}
|
||||
|
|
@ -96,19 +154,6 @@ class ContactController extends Controller
|
|||
return WebPortalContext::portalFromHost()->value;
|
||||
}
|
||||
|
||||
private function recipient(): string
|
||||
{
|
||||
// Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein
|
||||
// Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden.
|
||||
if (str_contains(request()->getHost(), 'pressekonto')) {
|
||||
$brand = config('domains.domains.pressekonto.brand', []);
|
||||
} else {
|
||||
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
|
||||
}
|
||||
|
||||
return $brand['contact_email'] ?? config('mail.from.address');
|
||||
}
|
||||
|
||||
private function ensureNotRateLimited(Request $request): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic;
|
|||
use Database\Factories\ContactMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\MassPrunable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -21,6 +22,11 @@ class ContactMessage extends Model
|
|||
/** @use HasFactory<ContactMessageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use MassPrunable;
|
||||
|
||||
/** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */
|
||||
public const SPAM_RETENTION_DAYS = 30;
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'topic',
|
||||
|
|
@ -47,6 +53,87 @@ class ContactMessage extends Model
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune).
|
||||
*
|
||||
* @return Builder<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}").
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Observers;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
|
|
@ -23,6 +24,8 @@ class ActivityLogObserver
|
|||
public function created(Model $model): void
|
||||
{
|
||||
match (true) {
|
||||
// Spam-Quarantäne gehört nicht in den Verlauf.
|
||||
$model instanceof ContactMessage && $model->status === ContactMessageStatus::Spam => null,
|
||||
$model instanceof ContactMessage => $this->activityLog->record(
|
||||
ActivityType::ContactRequest,
|
||||
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
|||
class NoPromotionalText implements ValidationRule
|
||||
{
|
||||
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
|
||||
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
||||
public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
||||
|
||||
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Services\Admin;
|
||||
|
||||
use App\Authorization\Permission as PermissionCatalog;
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\FailedLoginTally;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
|
|
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
|
|||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\Spam\TurnstileVerifier;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -251,6 +254,32 @@ class AdminSystemHealth
|
|||
: 'Imagick fehlt — Herkunftsprüfung'.($precheckEnabled ? ' und KI-Vorab-Check' : '').' der Bilder laufen ins Leere. Auf dem Server php8.4-imagick installieren und PHP-FPM neu laden.',
|
||||
];
|
||||
|
||||
// Spamschutz (Cloudflare Turnstile): Keys in Produktion gesetzt, und
|
||||
// Formulare, die bei nicht erreichbarem Cloudflare durchgelassen und
|
||||
// markiert wurden (Entscheidung 09.10.2026) — die gehören nachgeprüft.
|
||||
$turnstileEnabled = TurnstileVerifier::enabled();
|
||||
$checks[] = [
|
||||
'key' => 'turnstile',
|
||||
'label' => 'Spamschutz (Cloudflare Turnstile)',
|
||||
'status' => ($turnstileEnabled || ! app()->isProduction()) ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $turnstileEnabled
|
||||
? 'Bot-Prüfung für Kontakt-Widget und Registrierung ist aktiv.'
|
||||
: 'TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY fehlen — Kontakt-Widget und Registrierung laufen ohne Bot-Prüfung.',
|
||||
];
|
||||
|
||||
$unchecked = ActivityEvent::query()
|
||||
->where('type', ActivityType::BotCheckUnavailable->value)
|
||||
->where('occurred_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
$checks[] = [
|
||||
'key' => 'bot_check_unavailable',
|
||||
'label' => 'Formulare ohne Bot-Prüfung',
|
||||
'status' => $unchecked > 0 ? self::StatusWarn : self::StatusOk,
|
||||
'detail' => $unchecked > 0
|
||||
? sprintf('%d Formular(e) in 7 Tagen angenommen, während Cloudflare nicht erreichbar war — im Verlauf (Typ „Ohne Bot-Prüfung“) nachprüfen.', $unchecked)
|
||||
: 'In den letzten 7 Tagen wurde jedes Formular geprüft.',
|
||||
];
|
||||
|
||||
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
||||
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
||||
// nichts, muss aber sichtbar bleiben.
|
||||
|
|
|
|||
27
app/Services/Spam/BotCheck.php
Normal file
27
app/Services/Spam/BotCheck.php
Normal 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;
|
||||
}
|
||||
}
|
||||
136
app/Services/Spam/ContactSpamFilter.php
Normal file
136
app/Services/Spam/ContactSpamFilter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
44
app/Services/Spam/GibberishDetector.php
Normal file
44
app/Services/Spam/GibberishDetector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
app/Services/Spam/SpamLog.php
Normal file
29
app/Services/Spam/SpamLog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
151
app/Services/Spam/TurnstileVerifier.php
Normal file
151
app/Services/Spam/TurnstileVerifier.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
use App\Enums\BotCheckStatus;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Serverseitige Prüfung des Cloudflare-Turnstile-Tokens
|
||||
* (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, B1).
|
||||
*
|
||||
* Entscheidung 09.10.2026: Ist Cloudflare nicht erreichbar, wird nicht
|
||||
* blockiert, sondern durchgelassen und markiert (BotCheckStatus::Unavailable).
|
||||
* Das gilt für drei Fälle:
|
||||
* - Siteverify antwortet nicht (Timeout, Netzfehler, 5xx, internal-error),
|
||||
* - unsere Keys werden abgelehnt (Konfigurationsfehler, nicht Schuld des Nutzers),
|
||||
* - das Widget konnte im Browser nicht laden (Feld `turnstile_unavailable`).
|
||||
* Der letzte Fall ist vom Absender behauptet — darum bleibt er sichtbar
|
||||
* markiert und zählt in der Betriebsüberwachung. Wird er missbraucht,
|
||||
* schaltet TURNSTILE_FAIL_OPEN=false auf Ablehnen um.
|
||||
*
|
||||
* Die Hostname-Bindung prüft Cloudflare selbst (Widget-Einstellungen);
|
||||
* hier zählt zusätzlich die Aktion, damit ein Token nur für das Formular
|
||||
* gilt, für das es gelöst wurde.
|
||||
*/
|
||||
class TurnstileVerifier
|
||||
{
|
||||
public const ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/** Name des Token-Felds, das das Widget im Formular anlegt. */
|
||||
public const TOKEN_FIELD = 'cf-turnstile-response';
|
||||
|
||||
/** Vom Widget gesetzt, wenn das Skript im Browser nicht lud. */
|
||||
public const UNAVAILABLE_FIELD = 'turnstile_unavailable';
|
||||
|
||||
private const MAX_TOKEN_LENGTH = 2048;
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return filled(config('services.turnstile.site_key')) && filled(config('services.turnstile.secret_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein klassisches Formular (Token und Ausfall-Flag im Request).
|
||||
*/
|
||||
public function verifyRequest(Request $request, string $action): BotCheck
|
||||
{
|
||||
return $this->verify(
|
||||
$request->string(self::TOKEN_FIELD)->toString(),
|
||||
$request->boolean(self::UNAVAILABLE_FIELD),
|
||||
$action,
|
||||
$request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(?string $token, bool $clientUnavailable, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return new BotCheck(BotCheckStatus::Skipped);
|
||||
}
|
||||
|
||||
$token = trim((string) $token);
|
||||
|
||||
if ($token === '') {
|
||||
return $clientUnavailable
|
||||
? $this->unavailable('widget_not_loaded', $action, $ip)
|
||||
: $this->failed('missing_token', $action, $ip);
|
||||
}
|
||||
|
||||
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
|
||||
return $this->failed('token_too_long', $action, $ip);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('services.turnstile.timeout', 3))
|
||||
->post(self::ENDPOINT, array_filter([
|
||||
'secret' => config('services.turnstile.secret_key'),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]));
|
||||
} catch (ConnectionException) {
|
||||
return $this->unavailable('siteverify_unreachable', $action, $ip);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return $this->unavailable('siteverify_http_'.$response->status(), $action, $ip);
|
||||
}
|
||||
|
||||
$errors = (array) $response->json('error-codes', []);
|
||||
|
||||
if ($response->json('success') !== true) {
|
||||
if (in_array('internal-error', $errors, true)) {
|
||||
return $this->unavailable('siteverify_internal_error', $action, $ip);
|
||||
}
|
||||
|
||||
if (array_intersect(['missing-input-secret', 'invalid-input-secret'], $errors) !== []) {
|
||||
// Unser Fehler, nicht der des Absenders — laut melden, nicht blockieren.
|
||||
Log::error('Turnstile lehnt den Secret-Key ab — TURNSTILE_SECRET_KEY prüfen.', ['errors' => $errors]);
|
||||
|
||||
return $this->unavailable('invalid_secret', $action, $ip);
|
||||
}
|
||||
|
||||
return $this->failed(implode(',', $errors) ?: 'rejected', $action, $ip);
|
||||
}
|
||||
|
||||
$returnedAction = (string) $response->json('action', '');
|
||||
|
||||
// Die Cloudflare-Testkeys (1x…/2x…/3x…) liefern keine echte Aktion.
|
||||
if (! $this->usesTestSecret() && $returnedAction !== $action) {
|
||||
return $this->failed('action_mismatch', $action, $ip);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Passed);
|
||||
}
|
||||
|
||||
private function unavailable(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht verfügbar — durchgelassen und markiert.', $reason, $action, $ip);
|
||||
|
||||
if (! config('services.turnstile.fail_open', true)) {
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Unavailable, $reason);
|
||||
}
|
||||
|
||||
private function failed(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht bestanden.', $reason, $action, $ip);
|
||||
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
private function log(string $message, string $reason, string $action, ?string $ip): void
|
||||
{
|
||||
Log::channel('spam')->info($message, [
|
||||
'form' => $action,
|
||||
'reason' => $reason,
|
||||
'ip_prefix' => SpamLog::ipPrefix($ip),
|
||||
]);
|
||||
}
|
||||
|
||||
private function usesTestSecret(): bool
|
||||
{
|
||||
return (bool) preg_match('/^[123]x0{20,}/', (string) config('services.turnstile.secret_key'));
|
||||
}
|
||||
}
|
||||
40
app/Services/Web/ContactMessageNotifier.php
Normal file
40
app/Services/Web/ContactMessageNotifier.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,17 +23,19 @@ class ContentSecurityPolicy
|
|||
$own = array_merge(["'self'"], self::ownOrigins());
|
||||
$umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null;
|
||||
$errors = self::originOf((string) config('monitoring.browser.dsn'));
|
||||
// Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe.
|
||||
$turnstile = 'https://challenges.cloudflare.com';
|
||||
|
||||
$directives = [
|
||||
'default-src' => ["'self'"],
|
||||
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami],
|
||||
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami, $turnstile],
|
||||
'style-src' => [...$own, "'unsafe-inline'"],
|
||||
'img-src' => [...$own, 'data:', 'blob:', 'https:'],
|
||||
'font-src' => [...$own, 'data:'],
|
||||
'connect-src' => [...$own, $umami, $errors],
|
||||
'media-src' => [...$own],
|
||||
'object-src' => ["'none'"],
|
||||
'frame-src' => ["'self'"],
|
||||
'frame-src' => ["'self'", $turnstile],
|
||||
'frame-ancestors' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],
|
||||
|
|
|
|||
|
|
@ -147,4 +147,21 @@ return [
|
|||
'password' => env('BASIC_AUTH_PASSWORD'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bot-Registrierungen aufräumen (Spamschutz B4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `users:prune-bot-registrations` löscht über das Formular angelegte
|
||||
| Konten, die nach `after_days` weder bestätigt noch genutzt wurden.
|
||||
| Der tägliche Lauf ist aus, bis der Probelauf (--dry-run) auf Live
|
||||
| geprüft ist; Altkonten aus dem Legacy-Import sind nie betroffen.
|
||||
|
|
||||
*/
|
||||
|
||||
'prune_bot_registrations' => [
|
||||
'enabled' => (bool) env('USERS_PRUNE_BOT_REGISTRATIONS', false),
|
||||
'after_days' => 14,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ return [
|
|||
| monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene
|
||||
| Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces
|
||||
| market Kursdaten-Abruf (market:refresh)
|
||||
| csp CSP-Meldungen der Browser (Melde-Modus)
|
||||
| spam Spamschutz: abgewiesene und ungeprüfte Formulare
|
||||
| admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte)
|
||||
|
|
||||
*/
|
||||
|
|
@ -119,6 +121,10 @@ return [
|
|||
// CSP-Meldungen der Browser (Melde-Modus, CspReportController).
|
||||
'csp' => $topicChannel('csp'),
|
||||
|
||||
// Spamschutz: abgewiesene und ungeprüfte Formulare, nur Grund und
|
||||
// Netz-Präfix (TurnstileVerifier, ContactController).
|
||||
'spam' => $topicChannel('spam'),
|
||||
|
||||
'admin_slow' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/admin-slow.log'),
|
||||
|
|
|
|||
|
|
@ -64,4 +64,19 @@ return [
|
|||
'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false),
|
||||
'identify_users' => env('UMAMI_IDENTIFY_USERS', false),
|
||||
],
|
||||
|
||||
/*
|
||||
| Cloudflare Turnstile (Spamschutz, dev/sicherheit/ENTWICKLUNGSPLAN-
|
||||
| SPAMSCHUTZ-2026-10-09.md): Bot-Prüfung für Kontakt-Widget und
|
||||
| Registrierung. Ohne beide Keys ist die Prüfung aus (Tests, lokal ohne
|
||||
| Keys). Lokal die Cloudflare-Testkeys verwenden — die echten Keys sind an
|
||||
| die Live-Hostnamen gebunden. `fail_open`: Ist Cloudflare nicht
|
||||
| erreichbar, wird durchgelassen und markiert (Entscheidung 09.10.2026).
|
||||
*/
|
||||
'turnstile' => [
|
||||
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||
'timeout' => (int) env('TURNSTILE_TIMEOUT', 3),
|
||||
'fail_open' => (bool) env('TURNSTILE_FAIL_OPEN', true),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -224,6 +224,8 @@ return [
|
|||
'validation_message' => 'Bitte geben Sie eine Nachricht ein.',
|
||||
'validation_message_min' => 'Ihre Nachricht sollte mindestens :min Zeichen lang sein.',
|
||||
'validation_message_max' => 'Ihre Nachricht darf höchstens :max Zeichen lang sein.',
|
||||
'bot_check_failed' => 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte laden Sie die Seite neu und senden Sie Ihre Nachricht erneut.',
|
||||
'bot_check_waiting' => 'Sicherheitsprüfung läuft …',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ return [
|
|||
'validation_message' => 'Please enter a message.',
|
||||
'validation_message_min' => 'Your message should be at least :min characters long.',
|
||||
'validation_message_max' => 'Your message must not exceed :max characters.',
|
||||
'bot_check_failed' => 'The security check failed. Please reload the page and send your message again.',
|
||||
'bot_check_waiting' => 'Running security check …',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
<env name="TRUSTED_PROXIES" value="" force="true"/>
|
||||
<env name="SECURITY_HSTS" 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="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
|
|
|
|||
133
resources/views/components/turnstile.blade.php
Normal file
133
resources/views/components/turnstile.blade.php
Normal 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
|
||||
|
|
@ -163,6 +163,15 @@
|
|||
<a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a>
|
||||
</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). --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="contact-launcher-website">Website</label>
|
||||
|
|
|
|||
|
|
@ -19,13 +19,41 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
|
|||
#[Url(as: 'topic', except: 'all')]
|
||||
public string $topicFilter = 'all';
|
||||
|
||||
/** @var list<string> Auswahl für die Sammelaktion „Als Spam markieren“. */
|
||||
public array $selected = [];
|
||||
|
||||
public string $notification = '';
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->selected = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelaktion: ausgewählte Anliegen in die Spam-Quarantäne legen
|
||||
* (Spamschutz B3). Keine Mail, Löschung nach 30 Tagen.
|
||||
*/
|
||||
public function markSelectedAsSpam(): void
|
||||
{
|
||||
$this->authorize('compliance:manage');
|
||||
|
||||
$messages = ContactMessage::query()
|
||||
->whereKey(array_map('intval', $this->selected))
|
||||
->where('status', '!=', ContactMessageStatus::Spam->value)
|
||||
->get();
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$message->markAsSpam(auth()->user());
|
||||
}
|
||||
|
||||
$this->selected = [];
|
||||
$this->notification = __(':count Anliegen als Spam markiert.', ['count' => $messages->count()]);
|
||||
}
|
||||
|
||||
public function updatedTopicFilter(): void
|
||||
{
|
||||
$this->selected = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +70,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
|
|||
'messages' => $messages,
|
||||
'topicOptions' => ContactMessageTopic::cases(),
|
||||
'openCount' => ContactMessage::query()->open()->count(),
|
||||
'totalCount' => ContactMessage::query()->count(),
|
||||
'spamCount' => ContactMessage::query()->where('status', ContactMessageStatus::Spam->value)->count(),
|
||||
'totalCount' => ContactMessage::query()->where('status', '!=', ContactMessageStatus::Spam->value)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +82,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
|
|||
ContactMessageStatus::InProgress => 'hub',
|
||||
ContactMessageStatus::Resolved => 'ok',
|
||||
ContactMessageStatus::Rejected => 'err',
|
||||
ContactMessageStatus::Spam => 'muted',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
|
@ -67,14 +97,18 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
|
|||
/>
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<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-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
|
@ -89,6 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
|
|||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
<option value="resolved">{{ __('Erledigt') }}</option>
|
||||
<option value="rejected">{{ __('Abgelehnt') }}</option>
|
||||
<option value="spam">{{ __('Spam (Quarantäne)') }}</option>
|
||||
</flux:select>
|
||||
|
||||
<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">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (count($selected) > 0)
|
||||
<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>
|
||||
@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.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>{{ __('Status') }}</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>
|
||||
@forelse ($messages as $message)
|
||||
<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>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ $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) }}">
|
||||
{{ $message->status->label() }}
|
||||
</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>
|
||||
<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>
|
||||
@empty
|
||||
<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="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)]">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Services\Web\ContactMessageNotifier;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -56,6 +57,34 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
|
|||
$this->notification = __('Anliegen wieder geöffnet.');
|
||||
}
|
||||
|
||||
public function markAsSpam(): void
|
||||
{
|
||||
$this->authorize('compliance:manage');
|
||||
|
||||
$this->message()->markAsSpam(auth()->user());
|
||||
$this->notification = __('Als Spam markiert. Nach :days Tagen wird das Anliegen gelöscht.', ['days' => ContactMessage::SPAM_RETENTION_DAYS]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlalarm der Spam-Quarantäne: wieder öffnen und die Team-Mail
|
||||
* nachholen, die beim Eingang zurückgehalten wurde.
|
||||
*/
|
||||
public function releaseFromSpam(ContactMessageNotifier $notifier): void
|
||||
{
|
||||
$this->authorize('compliance:manage');
|
||||
|
||||
$message = $this->message();
|
||||
|
||||
if ($message->releaseFromSpam()) {
|
||||
$notifier->notify($message->refresh());
|
||||
$this->notification = __('Kein Spam: Anliegen wieder geöffnet, die Mail an das Team wurde nachgeholt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notification = __('Kein Spam: Anliegen wieder geöffnet.');
|
||||
}
|
||||
|
||||
private function finish(ContactMessageStatus $status, string $notification): void
|
||||
{
|
||||
$this->message()->update([
|
||||
|
|
@ -85,6 +114,7 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
|
|||
ContactMessageStatus::InProgress => 'hub',
|
||||
ContactMessageStatus::Resolved => 'ok',
|
||||
ContactMessageStatus::Rejected => 'err',
|
||||
ContactMessageStatus::Spam => 'muted',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
|
@ -155,6 +185,18 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
|
|||
<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>
|
||||
</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)
|
||||
<div>
|
||||
<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">
|
||||
{{ __('Ablehnen') }}
|
||||
</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
|
||||
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
|
||||
{{ __('Wieder öffnen') }}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\RegistrationType;
|
||||
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\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
|
@ -22,13 +26,24 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
|
|||
|
||||
public bool $terms_accepted = false;
|
||||
|
||||
/** Vom Turnstile-Widget gesetzt (Spamschutz B4). */
|
||||
public string $turnstileToken = '';
|
||||
|
||||
public bool $turnstileUnavailable = false;
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function register(): void
|
||||
public function register(TurnstileVerifier $turnstile, ActivityLog $activityLog): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
// Bots registrieren mit Zufallsnamen („ruLrlotwaCoVStmZetSxD“),
|
||||
// 389 Konten in 30 Tagen bis 07.10.2026 (Spamschutz B4).
|
||||
'name' => ['required', 'string', 'max:255', function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (is_string($value) && GibberishDetector::isRandomPersonName($value)) {
|
||||
$fail(__('Bitte geben Sie Ihren Vor- und Nachnamen an.'));
|
||||
}
|
||||
}],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
|
||||
'terms_accepted' => ['accepted'],
|
||||
|
|
@ -46,6 +61,21 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
|
|||
|
||||
RateLimiter::hit('register-ip|'.request()->ip(), 3600);
|
||||
|
||||
// Jede Registrierung verschickt eine Bestätigungsmail an die
|
||||
// angegebene Adresse — ohne Bot-Prüfung ein Werkzeug, fremde
|
||||
// Postfächer zu fluten. Ist Cloudflare nicht erreichbar, wird
|
||||
// durchgelassen und im Verlauf markiert (Entscheidung 09.10.2026).
|
||||
$botCheck = $turnstile->verify($this->turnstileToken, $this->turnstileUnavailable, 'register', request()->ip());
|
||||
|
||||
if ($botCheck->blocks()) {
|
||||
$this->turnstileToken = '';
|
||||
$this->dispatch('turnstile-reset');
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'turnstile' => __('Die Sicherheitsprüfung ist fehlgeschlagen. Bitte warten Sie einen Moment und versuchen Sie es erneut.'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Die bestätigte AGB-/Datenschutz-Checkbox wird als DSGVO-Einwilligung
|
||||
// mit Zeitstempel festgehalten (users.gdpr_consent_at).
|
||||
unset($validated['terms_accepted']);
|
||||
|
|
@ -65,6 +95,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
|
|||
// SendEmailVerificationNotification-Listener die Bestätigungsmail.
|
||||
event(new Registered($user = User::create($validated)));
|
||||
|
||||
if ($botCheck->needsReview()) {
|
||||
$activityLog->record(
|
||||
ActivityType::BotCheckUnavailable,
|
||||
sprintf('Registrierung von %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $user->email, $botCheck->reason),
|
||||
user: $user,
|
||||
subject: $user,
|
||||
meta: ['form' => 'register', 'reason' => $botCheck->reason],
|
||||
);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
// Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das
|
||||
|
|
@ -151,6 +191,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
|
|||
@enderror
|
||||
</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">
|
||||
<span wire:loading.remove wire:target="register">{{ __('Konto erstellen') }}</span>
|
||||
<span wire:loading wire:target="register">{{ __('Konto wird angelegt …') }}</span>
|
||||
|
|
|
|||
|
|
@ -207,6 +207,49 @@
|
|||
Aufbewahrungspflichten oder berechtigten Interessen an einer weiteren Speicherung
|
||||
entgegenstehen.
|
||||
</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)
|
||||
<h2>7. Kundenkonto und Registrierung auf dem Hub</h2>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Console\Commands\ExpireGrandfathering;
|
|||
use App\Console\Commands\FetchMailBounces;
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
use App\Console\Commands\MonitoringHeartbeat;
|
||||
use App\Console\Commands\PruneBotRegistrations;
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Console\Commands\PurgeConversionEvents;
|
||||
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
|
||||
|
|
@ -20,6 +21,7 @@ use App\Console\Commands\PurgeRedactionSnapshots;
|
|||
use App\Console\Commands\RefreshMarketData;
|
||||
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
||||
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\FailedLoginTally;
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Console\Scheduling\Event;
|
||||
|
|
@ -70,6 +72,16 @@ Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
|
|||
->runInBackground()
|
||||
->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)
|
||||
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
|
||||
->dailyAt('03:00')
|
||||
|
|
@ -247,12 +259,13 @@ Schedule::command(MonitoringHeartbeat::class)
|
|||
->onFailure($alertOnFailure('monitoring:heartbeat'));
|
||||
|
||||
// Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen
|
||||
// (täglich 04:05, config monitoring.failed_logins.retention_days).
|
||||
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class]])
|
||||
// (täglich 04:05, config monitoring.failed_logins.retention_days) und
|
||||
// Spam-Quarantäne des Kontaktformulars nach 30 Tagen (Spamschutz B3).
|
||||
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class, ContactMessage::class]])
|
||||
->dailyAt('04:05')
|
||||
->withoutOverlapping(30)
|
||||
->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
|
||||
// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -99,3 +101,103 @@ test('customers cannot access the contact queue', function () {
|
|||
->get('https://pressekonto.test/admin/contact-messages')
|
||||
->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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
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');
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
|
|
|||
89
tests/Feature/Auth/PruneBotRegistrationsTest.php
Normal file
89
tests/Feature/Auth/PruneBotRegistrationsTest.php
Normal 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();
|
||||
});
|
||||
132
tests/Feature/Auth/RegistrationSpamProtectionTest.php
Normal file
132
tests/Feature/Auth/RegistrationSpamProtectionTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -28,6 +28,7 @@ test('every topic channel writes its own daily file with the shared retention',
|
|||
'monitoring' => ['monitoring', 'monitoring'],
|
||||
'market' => ['market', 'market'],
|
||||
'csp' => ['csp', 'csp'],
|
||||
'spam' => ['spam', 'spam'],
|
||||
'admin_slow' => ['admin_slow', 'admin-slow'],
|
||||
]);
|
||||
|
||||
|
|
|
|||
287
tests/Feature/Web/ContactSpamProtectionTest.php
Normal file
287
tests/Feature/Web/ContactSpamProtectionTest.php
Normal 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');
|
||||
});
|
||||
43
tests/Feature/Web/QuarantineContactSpamTest.php
Normal file
43
tests/Feature/Web/QuarantineContactSpamTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -74,7 +74,7 @@ abstract class TestCase extends BaseTestCase
|
|||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
tests/Unit/GibberishDetectorTest.php
Normal file
21
tests/Unit/GibberishDetectorTest.php
Normal 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],
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue