Compare commits
4 commits
28e78f5d57
...
9cb39b6d2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb39b6d2f | |||
| 64d53a58c0 | |||
| beae7e2b13 | |||
| 43420f0422 |
53 changed files with 2200 additions and 48 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),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -264,3 +264,4 @@ Ausfall des Anbieters keinen Deploy abbricht.
|
|||
| 07.10.2026 | `v1.4.1` | Hotfix für den Abgleich: Entzogen wird nur noch, was keine Grundlage mehr hat; das Kommando läuft in einer Transaktion, mit `--dry-run` und Notbremse ab 5 %. Rechte aus dem Backup vor dem Deploy wiederhergestellt (`INSERT IGNORE`, 19.598 eingefügt, danach 19.607). Hinweis: `mysql` auf dem Server braucht `-h`/`-P` aus der `.env`, sonst „Access denied“ für `…@'localhost'`; ohne Passwort geht es über `php artisan tinker`. Probelauf 19.607 → 19.316, danach ausgeführt. Wiederholung am 08.10. ohne Änderung (19.316 Rechte, 14.911 User). Die Nacht verlief ohne Auffälligkeiten. |
|
||||
| 08.10.2026 | `v1.5.0` | API-Zugang an die Buchung gebunden (403 ohne Abo/Buchung, Tokens bleiben), optionales Token-Ablaufdatum, `sanctum:prune-expired` täglich 04:10. Vorab auf Live geprüft: nginx antwortet direkt, kein Proxy/CDN; Cookies `secure`/`httponly`/`samesite=lax`, HSTS fehlte. Nach dem Deploy: `strict-transport-security: max-age=31536000`, `trustedproxy.proxies` = `null`. Auswirkung der Buchungsprüfung: 2 Konten haben die API in den letzten 30 Tagen genutzt, beide weiterhin mit Zugang. Keine Migration. |
|
||||
| 08.10.2026 | `v1.6.0` | Mit Migration (`users.sessions_revoked_at_ms`, Batch 22). Härtung aus Phase 4 des Sicherheitsaudits: „Andere Geräte abmelden“, IP-weites Login-Limit (20 Fehlversuche in 15 Minuten), dauerhafte Session-Sperrmarke, Passwörter ab 12 Zeichen mit Leak-Abgleich, CSP im Melde-Modus (`storage/logs/csp-*.log`), npm `shell-quote`/`source-map-js`. Nach dem Deploy: Header `content-security-policy-report-only` vorhanden, Umami- und GlitchTip-Host in `script-src`/`connect-src`. CSP-Log in einigen Tagen auswerten. |
|
||||
| 08.10.2026 | `v1.6.1` | Vorher auf dem Server `php8.4-imagick` installiert und FPM neu geladen: Die Erweiterung fehlte auf Live, Herkunftsprüfung und KI-Vorab-Check der Bilder liefen seitdem ins Leere. Release: Zeile „PHP-Erweiterung Imagick“ in der Betriebsüberwachung, gebaute Livewire-Anfragen von Scannern (GlitchTip 38, 39) als 400 statt 500/419, Browser-Filter für abgebrochene Alpine-Übergänge (GlitchTip 18). Keine Migration. Nach dem Deploy: Betriebsüberwachung „Imagick ist geladen“. |
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
| **ab 15.10.2026** | CSP-Meldungen auswerten (Melde-Modus seit `v1.6.0`). | Auf Live `tail -n 200 storage/logs/csp-*.log`; fehlende Quellen in `App\Support\ContentSecurityPolicy` ergänzen, danach über das Scharfschalten entscheiden (`'unsafe-inline'`/`'unsafe-eval'` bleiben bis zu einer Nonce-Lösung). |
|
||||
| nächstes Release | Übergangs-Fallback der Sperrmarke entfernen. | `SessionRevocation::legacyCachedMark()` und den Test „a revocation from before v1.6.0 …“ streichen. |
|
||||
| nächstes Release | Liste und Kennzahl „Aktive Sessions“ auf der Sicherheitsseite lesen die Tabelle `sessions`, Live speichert Sessions in Redis — dort immer leer. | Korrigieren oder entfernen; der Knopf „Andere Geräte abmelden“ ist davon unabhängig. |
|
||||
| nach Deploy Spamschutz | Altbestand und Bot-Konten aufräumen, Datenschutztext freigeben. | Schritte in [ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md](ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md), Abschnitt 0: beide Befehle erst mit `--dry-run`, dann `USERS_PRUNE_BOT_REGISTRATIONS=true`; Abschnitt 6a der Datenschutzerklärung juristisch prüfen lassen. |
|
||||
| 7 Tage nach Deploy Spamschutz | Wirkung auswerten. | Spam-Filter der Kontakt-Queue, `storage/logs/spam-*.log`, Betriebsüberwachung „Formulare ohne Bot-Prüfung“; Regeln nachschärfen oder lockern. |
|
||||
| bei Gelegenheit | Außerhalb des Audits: eigene MySQL-Benutzer je Projekt auf `global-mysql`, MySQL-Root-Passwort ändern (steht in der Git-Historie), lokalen `APP_KEY` reparieren. | – |
|
||||
|
||||
**Ausführung:** Befehle direkt im Dev-Container (`php artisan …`, ohne Sail – siehe `.ai/rules/general.md`)
|
||||
|
|
|
|||
197
dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md
Normal file
197
dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Entwicklungsplan – Spamschutz für öffentliche Formulare
|
||||
|
||||
**Anlass:** Über das Kontakt-Widget kommen täglich Spam-Mails an die Brand-Adressen. Ziel ist, dass Spam gar nicht erst im System landet oder Mails auslöst.
|
||||
|
||||
**Datengrundlage:** lokale Datenbank, Stand 07.10.2026, Zeitraum der letzten 30 Tage.
|
||||
|
||||
**Stand:** 09.10.2026 – Phasen 1 bis 3 umgesetzt (noch nicht released), Entscheidungen am 09.10.2026 getroffen (Abschnitt 4). Umsetzung und Schritte nach dem Deploy: Abschnitt 0.
|
||||
|
||||
## 0. Umsetzungsstand
|
||||
|
||||
| Baustein | Umsetzung | Tests |
|
||||
| --- | --- | --- |
|
||||
| B1 Turnstile | `App\Services\Spam\TurnstileVerifier`, Komponente `<x-turnstile>`, Config `services.turnstile`, CSP um `challenges.cloudflare.com` (script-src, frame-src) ergänzt | `tests/Feature/Web/ContactSpamProtectionTest.php`, `tests/Feature/Auth/RegistrationSpamProtectionTest.php` |
|
||||
| B2 Inhaltsfilter | `App\Services\Spam\ContactSpamFilter`: fremde Links (Links auf eigene Seiten erlaubt, weil Korrekturwünsche sie nennen), Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden. Nur für Gäste und unbestätigte Konten | wie oben, `tests/Unit/GibberishDetectorTest.php` |
|
||||
| B3 Quarantäne | Status `ContactMessageStatus::Spam`: keine Mail, nicht im Zähler, kein Verlaufseintrag, Löschung nach 30 Tagen (`model:prune`). Admin: Filter „Spam“, Sammelaktion, „Als Spam markieren“, „Kein Spam“ (holt die Mail einmal nach) | `tests/Feature/Admin/ContactMessagesAdminTest.php` |
|
||||
| B4 Registrierung | Turnstile und Namensregel (`GibberishDetector::isRandomPersonName`, 0 Fehltreffer unter rund 67.000 Personennamen); Befehl `users:prune-bot-registrations` mit Probelauf, Zeitplan täglich 03:45, aber aus bis `USERS_PRUNE_BOT_REGISTRATIONS=true` | `tests/Feature/Auth/PruneBotRegistrationsTest.php` |
|
||||
| Altbestand | Befehl `contact-messages:quarantine-spam` mit Probelauf | `tests/Feature/Web/QuarantineContactSpamTest.php` |
|
||||
| B6 Beobachtung | Log-Kanal `spam` (Grund, Formular, IP-Präfix, nie Inhalte); Betriebsüberwachung: „Spamschutz (Cloudflare Turnstile)“ und „Formulare ohne Bot-Prüfung“ (7 Tage) | `tests/Feature/Admin/SystemHealthTest.php` |
|
||||
| Datenschutz | Abschnitt 6a „Schutz vor automatisierten Anfragen (Cloudflare Turnstile)“ und Hinweis zur Quarantäne in Abschnitt 6 — **Vorschlag, juristische Freigabe steht aus** | – |
|
||||
|
||||
**Ausfall von Cloudflare (Entscheidung 1, angepasst):** Es wird nie blockiert. Formulare, bei denen Cloudflare nicht erreichbar war, werden angenommen und markiert:
|
||||
- im Aktivitätsverlauf als Typ „Ohne Bot-Prüfung“;
|
||||
- in der Kontakt-Queue als Badge „ohne Bot-Prüfung“;
|
||||
- in der Betriebsüberwachung als Warnung.
|
||||
|
||||
Das gilt in drei Fällen: Siteverify antwortet nicht, Cloudflare lehnt unseren Secret-Key ab, oder das Widget lud im Browser nicht. Den letzten Fall meldet der Browser selbst und könnte deshalb missbraucht werden. Sollte das passieren, schaltet `TURNSTILE_FAIL_OPEN=false` auf Ablehnen um.
|
||||
|
||||
**Messung am Altbestand (lokal, Stand 07.10.):**
|
||||
- `contact-messages:quarantine-spam` markiert mit den Live-Hostnamen 125 offene Gast-Anfragen. Die echten Korrekturanfragen KT-32, KT-33 und KT-161 bleiben offen.
|
||||
- Was der Filter nicht erkennt, wird über die Sammelaktion markiert. Das sind vor allem die Preisfragen in wechselnden Sprachen und die Newsletter-Bitten.
|
||||
- `users:prune-bot-registrations --dry-run` findet 392 Konten. 379 davon haben einen Zufallsnamen, 13 sind echt wirkende, aber seit über 14 Tagen unbestätigte und ungenutzte Konten.
|
||||
|
||||
**Nach dem Deploy auf Live:**
|
||||
1. Im Cloudflare-Dashboard prüfen, dass das Turnstile-Widget alle drei Live-Hostnamen kennt. Danach einmal das Kontakt-Widget öffnen und absenden.
|
||||
2. `php artisan contact-messages:quarantine-spam --dry-run` ausführen, die Liste prüfen, dann ohne `--dry-run` ausführen.
|
||||
3. `php artisan users:prune-bot-registrations --dry-run` ausführen. Nach Freigabe der Liste einmal ohne `--dry-run` ausführen und `USERS_PRUNE_BOT_REGISTRATIONS=true` in die `.env` setzen (`config:cache` läuft im Deploy-Skript).
|
||||
4. Lokal die Cloudflare-Testkeys in die `.env` eintragen, weil die echten Keys an die Live-Hostnamen gebunden sind.
|
||||
5. Nach 7 Tagen auswerten: Spam-Filter in der Kontakt-Queue, `storage/logs/spam-*.log` und die Betriebsüberwachung.
|
||||
|
||||
## 1. Befund
|
||||
|
||||
### Kontakt-Widget (`contact_messages`)
|
||||
|
||||
- 141 Anfragen in 30 Tagen, das sind 2 bis 9 pro Tag auf allen drei Portalen. Jede löst eine Mail aus.
|
||||
- Davon kommen 5 von angemeldeten Kunden (`user_id` gesetzt). Nach Durchsicht der letzten 70 sind das die einzigen echten. Der Rest ist Spam.
|
||||
- Es gibt fünf Spam-Familien:
|
||||
|
||||
| Muster | Anzahl (30 T.) | Beispiel |
|
||||
| --- | --- | --- |
|
||||
| Link im Text (telegra.ph, tinyurl, `<a href>`, SEO-Angebote) | 56 | „2026 Rolls-Royce Phantom for $15,000 https://telegra.ph/…“ |
|
||||
| Floskel plus Meta-Beschreibung der Seite | 43 | „Ich wünsche weitere Informationen. Bitte kontaktieren Sie mich per E-Mail — aktuelle pressemitteilungen aus der deutschen wirtschaft.“ |
|
||||
| Preisfrage in wechselnden Sprachen | regelmäßig | „Kaixo, zure prezioa jakin nahi nuen.“ (Name „RobertHix“, „Robertfielt“) |
|
||||
| Zeichensalat | vereinzelt | Name „NAEWTRER2720160NEYRTHYT“, Text „METRYTRE2720160MAWRERGTRH“ |
|
||||
| Newsletter-Bitte auf Englisch | vereinzelt | „Hi! I'd like to subscribe to your newsletter.“ |
|
||||
|
||||
- **Herkunft:** wenige Netze. `212.30.36.x` allein bringt 32 Anfragen, `158.173.79.x` 13 und `77.90.185.x` 11. Das Limit pro IP (5 pro Stunde) greift nicht, weil die Absender innerhalb des Netzes die IP wechseln.
|
||||
- **Vorhandener Schutz:** zwei Honeypot-Felder (`website`, `homepage`), ein Limit pro IP und `throttle:10,1`. Das hält diese Bots nicht auf. Sie füllen die Honeypots offenbar nicht aus und laden die Seite vorher (`page_url` ist immer gesetzt).
|
||||
|
||||
### Registrierung – größer als das Kontaktformular
|
||||
|
||||
- 452 neue Konten in 30 Tagen. 389 davon sind Bot-Konten: Name als Zufallsbuchstaben („ruLrlotwaCoVStmZetSxD“), E-Mail nie bestätigt, keiner Firma zugeordnet. Das sind 6 bis 21 pro Tag.
|
||||
- Echte Neukunden im selben Zeitraum: 59 (bestätigt).
|
||||
- **Jedes Bot-Konto löst eine Bestätigungsmail an eine fremde Adresse aus.** Darunter sind SMS-Gateways von US-Mobilfunkanbietern (`tmomail.net`, `vtext.com`). Das ist bekanntes Sign-up-Bombing:
|
||||
- Die Täter nutzen unseren Mailversand, um Postfächer zu fluten.
|
||||
- Das schadet unserer Absender-Reputation und damit der Zustellung echter Mails.
|
||||
- Vorhandener Schutz: nur ein Limit pro IP (10 pro Stunde).
|
||||
|
||||
### Übrige öffentliche Formulare
|
||||
|
||||
Diese Formulare haben einen Honeypot und ein Limit. Spam fällt dort noch nicht auf (z. B. 14 Rechtsanfragen in 30 Tagen). Sie verschicken aber Mails an Adressen, die der Absender angibt:
|
||||
|
||||
- Widerruf
|
||||
- Kündigung
|
||||
- Meldeformular
|
||||
- Rechtsanfrage
|
||||
|
||||
## 2. Bausteine
|
||||
|
||||
### B1 – Cloudflare Turnstile (Kern)
|
||||
|
||||
**Konfiguration**
|
||||
- Neuer Eintrag `services.turnstile` mit `site_key`, `secret_key` und `enabled`. Grund: Wegen `config:cache` darf `env()` nur in `config/` stehen.
|
||||
- Die Keys stehen bereits in der `.env`. Es sind echte Keys (`0x…`), keine Testkeys.
|
||||
|
||||
**Serverseitige Prüfung**
|
||||
- Ein Dienst `TurnstileVerifier` ruft `POST https://challenges.cloudflare.com/turnstile/v0/siteverify` auf.
|
||||
- Mitgesendet werden `secret`, `response` und `remoteip`.
|
||||
- Geprüft werden `success`, `hostname` (gegen die drei Portal-Domains) und `action` (pro Formular).
|
||||
- Timeout 3 Sekunden.
|
||||
- Ein Token gilt nur einmal und höchstens 300 Sekunden.
|
||||
|
||||
**Blade-Komponente `<x-turnstile action="…" />`**
|
||||
- Das Skript wird nur dort geladen, wo ein Formular sichtbar ist.
|
||||
- Im Kontakt-Widget lädt es erst beim Öffnen des Panels (`render=explicit`). Normale Seitenaufrufe stellen so keine Verbindung zu Cloudflare her.
|
||||
- Modus „Managed“: meist unsichtbar, nur bei Verdacht eine Checkbox.
|
||||
|
||||
**Livewire (Registrierung)**
|
||||
- Den Container mit `wire:ignore` ausnehmen.
|
||||
- Ein Callback setzt das Token in ein Property.
|
||||
- Nach einem Validierungsfehler `turnstile.reset()` aufrufen. Sonst scheitert der zweite Versuch am verbrauchten Token.
|
||||
|
||||
**CSP:** `https://challenges.cloudflare.com` muss in `script-src` und `frame-src` aufgenommen werden (`App\Support\ContentSecurityPolicy`). Das gehört vor das Scharfschalten der CSP und deckt sich mit der Wiedervorlage ab 15.10.
|
||||
|
||||
**Tests und lokale Entwicklung**
|
||||
- `phpunit.xml` leert die Keys. Die Tests nutzen `Http::fake`.
|
||||
- Lokal kommen die Cloudflare-Testkeys in die `.env` (`1x…` = besteht immer, `2x…` = scheitert immer). Die echten Keys sind an die Live-Hostnamen gebunden.
|
||||
|
||||
**Cloudflare-Dashboard:** Beim Widget müssen alle Hostnamen eingetragen sein: `pressekonto.com`, `presseecho.de`, `businessportal24.com` sowie weitere Live-Domains aus `config/domains.php`.
|
||||
|
||||
### B2 – Inhaltsfilter fürs Kontaktformular (zweite Linie)
|
||||
|
||||
Diese Regeln gelten nur für **nicht angemeldete** Absender. Kunden bleiben ungefiltert.
|
||||
|
||||
- **Links:** URL, `www.` oder `<a href` im Text. Das deckt 56 Fälle ab. Eine Regex dafür gibt es bereits in `NoPromotionalText`. Zusätzlich die vorhandene `BlacklistService` nutzen.
|
||||
- **Floskel-Erkennung:** Text enthält die Meta-Beschreibung oder den Claim der Seite. Das deckt 43 Fälle ab.
|
||||
- **Zeichensalat:** Name ohne Vokalstruktur oder mit Ziffernblock in Großbuchstaben, wie in den Beispielen.
|
||||
- **Zeitfalle:** ein signierter Zeitstempel im Formular. Absenden unter 3 Sekunden oder nach mehr als 2 Stunden gilt als verdächtig.
|
||||
- **E-Mail:** Die Regel `PlausibleEmailDomain` gibt es schon, sie wird mitbenutzt.
|
||||
|
||||
### B3 – Spam-Quarantäne statt Löschen
|
||||
|
||||
**Neuer Status `ContactMessageStatus::Spam`**
|
||||
- Gefilterte Anfragen werden mit dem Grund in `payload` gespeichert.
|
||||
- Für sie geht **keine Mail** raus.
|
||||
- Sie zählen nicht im Nav-Zähler und stehen nicht im Standardfilter der Admin-Queue.
|
||||
- Fehlalarme lassen sich so prüfen und mit „Kein Spam“ zurückholen. Die Mail wird dann nachgeholt.
|
||||
|
||||
**Antwort an den Absender:** immer dieselbe neutrale Erfolgsmeldung, wie beim Honeypot heute. Der Bot lernt nichts.
|
||||
|
||||
Ausnahme ist ein fehlendes oder ungültiges Turnstile-Token. Dann gibt es eine sichtbare Fehlermeldung („Bitte bestätigen Sie, dass Sie kein Roboter sind“), denn ein echter Mensch muss es erneut versuchen können.
|
||||
|
||||
**Aufräumen:** Spam-Einträge werden nach 30 Tagen automatisch gelöscht (Scheduler, `prune`).
|
||||
|
||||
**Admin**
|
||||
- In der Liste: Sammelaktion „Als Spam markieren“.
|
||||
- In der Detailansicht: „Kein Spam“.
|
||||
|
||||
### B4 – Registrierung absichern
|
||||
|
||||
- Turnstile auf `/register` (B1).
|
||||
- Zufallsnamen ablehnen: Muster wie „ruLrlotwaCoVStmZetSxD“, also ein Wort ab 12 Buchstaben mit vielen Großbuchstaben mitten im Wort. Die Fehlermeldung bleibt neutral. Das ist bewusst eng gefasst, damit echte Namen nie getroffen werden.
|
||||
- **Bot-Konten aufräumen:** Ein Scheduler-Befehl löscht unbestätigte Konten nach 14 Tagen.
|
||||
|
||||
**Achtung:** Im Bestand sind 66.870 unbestätigte Altkonten aus dem Legacy-Import. Diese dürfen nie erfasst werden. Gelöscht wird deshalb nur, wenn alle drei Bedingungen gelten:
|
||||
1. das Konto wurde über das neue Registrierungsformular angelegt, also nach dem Go-live und ohne Legacy-ID;
|
||||
2. die E-Mail wurde nie bestätigt;
|
||||
3. das Konto hat keine Firma, keine Meldung, keine Bestellung und keinen Login nach der Registrierung.
|
||||
|
||||
Zuerst gibt es einen Probelauf mit `--dry-run`.
|
||||
|
||||
### B5 – Übrige öffentliche Formulare
|
||||
|
||||
- Dieselbe Komponente `<x-turnstile>` kommt in diese Formulare:
|
||||
- Widerruf
|
||||
- Kündigung
|
||||
- Meldeformular
|
||||
- PM-Meldung (Link anfordern und Anliegen)
|
||||
- Rechtsanfrage
|
||||
- Newsroom verwalten
|
||||
- **Kündigung und Widerruf sind gesetzlich vorgeschrieben** (§ 312k BGB, Widerrufsrecht). Ist Cloudflare nicht erreichbar, werden sie dort **trotzdem angenommen** (fail-open) und nur als „ohne Bot-Prüfung“ im Log vermerkt.
|
||||
|
||||
### B6 – Beobachtung
|
||||
|
||||
- Jede abgewiesene Anfrage wird mit Formular, Grund und IP-Präfix gezählt, ohne Inhalt. Dafür ist ein eigener Log-Kanal `spam` vorgesehen, analog zu `csp`.
|
||||
- Die Betriebsüberwachung bekommt einen Check: In Produktion sind die Turnstile-Keys gesetzt.
|
||||
- Nach 7 Tagen wird ausgewertet, wie viel durchkommt und wie viel in der Quarantäne liegt.
|
||||
|
||||
## 3. Reihenfolge
|
||||
|
||||
| Phase | Inhalt | Wirkung |
|
||||
| --- | --- | --- |
|
||||
| 1 | B1 + B2 + B3 fürs Kontakt-Widget, CSP-Ergänzung | Die täglichen Spam-Mails hören auf. |
|
||||
| 2 | B4: Turnstile und Namensregel bei der Registrierung | Keine Bestätigungsmails mehr an fremde Postfächer. |
|
||||
| 3 | B4: Aufräumbefehl für Bot-Konten (erst Probelauf) | Rund 390 Bot-Konten weg, saubere Kennzahlen. |
|
||||
| 4 | B5: übrige Formulare | Gleicher Schutz überall. |
|
||||
| 5 | B6: Auswertung nach 7 Tagen | Schwellen nachschärfen oder lockern. |
|
||||
|
||||
Phasen 1 und 2 passen in ein Release. Es ist **keine Migration** nötig: `contact_messages.status` ist eine String-Spalte (`string(40)`), der Status `spam` ist nur ein neuer Enum-Wert.
|
||||
|
||||
## 4. Entscheidungen (09.10.2026)
|
||||
|
||||
Alle wie empfohlen, mit einer Anpassung bei Nr. 1: Bei nicht erreichbarem Cloudflare wird **überall** durchgelassen und markiert, also auch bei Kontaktformular und Registrierung. Nr. 4: Ein Textvorschlag ist eingebaut.
|
||||
|
||||
1. **Cloudflare nicht erreichbar.** Empfehlung:
|
||||
- Kontaktformular und Registrierung: fail-closed, mit der Meldung „bitte später erneut“.
|
||||
- Kündigung und Widerruf: fail-open (siehe B5).
|
||||
2. **Altbestand im Kontakt-Postfach.** Rund 140 offene Spam-Anfragen der letzten 30 Tage nachträglich als Spam markieren? Empfehlung: ja, per einmaligem Befehl mit Probelauf, nach denselben Regeln wie B2.
|
||||
3. **Bot-Konten löschen** (B4, Phase 3). Empfehlung: ja, aber erst nach deiner Freigabe der Probelauf-Liste.
|
||||
4. **Datenschutzerklärung.** Turnstile überträgt die IP und Browsermerkmale an Cloudflare (USA, EU-US Data Privacy Framework). Ein Absatz in `datenschutz.blade.php` ist nötig, Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO (Missbrauchsschutz). Den Text sollte jemand juristisch freigeben.
|
||||
5. **Links von Gästen.** Komplett sperren oder nur ab einem Link in Kombination mit einem weiteren Signal? Empfehlung: Gäste dürfen keine Links senden. Echte Anfragen kamen in der Stichprobe ohne Link oder von angemeldeten Kunden.
|
||||
|
||||
## 5. Weitere Ideen (nicht eingeplant)
|
||||
|
||||
- Ein Limit pro /24-Netz zusätzlich zum Limit pro IP. Das würde `212.30.36.x` direkt bremsen, Turnstile macht das aber weitgehend überflüssig.
|
||||
- Statt einer Mail pro Anfrage eine tägliche Sammelmail für das Kontakt-Postfach.
|
||||
- Einwegadressen-Domains wie `notboxletters.com` und `belettersmail.com` als kleine Sperrliste. Sie wechseln aber ständig.
|
||||
- KI-Klassifizierung der Nachricht. Für diese Mengen ist das überdimensioniert. Sinnvoll erst, wenn nach Turnstile noch nennenswert Spam durchkommt.
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"resources/css/portal.css": {
|
||||
"file": "portal-x09-Mi7V.css",
|
||||
"file": "portal-DcXrzMms.css",
|
||||
"src": "resources/css/portal.css",
|
||||
"isEntry": true
|
||||
},
|
||||
|
|
|
|||
1
public/build/portal/portal-DcXrzMms.css
Normal file
1
public/build/portal/portal-DcXrzMms.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"resources/css/web/theme-businessportal24.css": {
|
||||
"file": "theme-businessportal24-CDRq7Rn6.css",
|
||||
"file": "theme-businessportal24-BEiTrV1U.css",
|
||||
"src": "resources/css/web/theme-businessportal24.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-presseecho.css": {
|
||||
"file": "theme-presseecho-hPNcGgVd.css",
|
||||
"file": "theme-presseecho-Bxul2KWf.css",
|
||||
"src": "resources/css/web/theme-presseecho.css",
|
||||
"isEntry": true
|
||||
},
|
||||
"resources/css/web/theme-pressekonto.css": {
|
||||
"file": "theme-pressekonto-BNifjlS-.css",
|
||||
"file": "theme-pressekonto-BgD1Lny4.css",
|
||||
"src": "resources/css/web/theme-pressekonto.css",
|
||||
"isEntry": true
|
||||
},
|
||||
|
|
|
|||
1
public/build/web/theme-businessportal24-BEiTrV1U.css
Normal file
1
public/build/web/theme-businessportal24-BEiTrV1U.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/web/theme-presseecho-Bxul2KWf.css
Normal file
1
public/build/web/theme-presseecho-Bxul2KWf.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/web/theme-pressekonto-BgD1Lny4.css
Normal file
1
public/build/web/theme-pressekonto-BgD1Lny4.css
Normal file
File diff suppressed because one or more lines are too long
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>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __(':count Einträge', ['count' => $messages->total()]) }}
|
||||
</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