feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
43420f0422
commit
beae7e2b13
40 changed files with 1992 additions and 40 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue