feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile

- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:49:58 +00:00
parent 61f449f483
commit 06d2889a4d
40 changed files with 1992 additions and 40 deletions

View file

@ -0,0 +1,133 @@
{{--
Cloudflare Turnstile (Spamschutz B1, dev/sicherheit/ENTWICKLUNGSPLAN-
SPAMSCHUTZ-2026-10-09.md). Serverseitig prüft TurnstileVerifier.
- Das Skript lädt erst, wenn das Widget sichtbar wird (Kontakt-Widget:
beim Öffnen) — normale Seitenaufrufe sprechen Cloudflare nicht an.
- Klassisches Formular: Turnstile legt das Feld `cf-turnstile-response`
selbst an. Wird abgeschickt, bevor das Token da ist, wartet das Formular
und sendet automatisch nach.
- Livewire: `wire-token` / `wire-unavailable` nennen die Properties, die
das Widget ohne Request setzt; `turnstile-reset` (Browser-Event) holt
nach einer abgelehnten Prüfung ein frisches Token.
- Lädt das Skript nicht (Cloudflare nicht erreichbar, Netz blockiert)
oder ist das Widget falsch konfiguriert, meldet es `turnstile_unavailable`:
Der Server lässt dann durch und markiert (Entscheidung 09.10.2026).
Nicht bestandene Challenges (Fehlercodes 300xxx/600xxx) zählen nicht
als Ausfall — Turnstile versucht es selbst erneut.
--}}
@props([
'action',
'wireToken' => null,
'wireUnavailable' => null,
])
@if (\App\Services\Spam\TurnstileVerifier::enabled())
<div wire:ignore
x-data="{
config: @js([
'sitekey' => (string) config('services.turnstile.site_key'),
'action' => $action,
'language' => app()->getLocale() === 'en' ? 'en' : 'de',
'wireToken' => $wireToken,
'wireUnavailable' => $wireUnavailable,
]),
token: '',
unavailable: false,
waiting: false,
widgetId: null,
init() {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.load();
}
});
observer.observe(this.$el);
const form = this.$el.closest('form');
if (form && ! this.config.wireToken) {
form.addEventListener('submit', (event) => {
if (this.token || this.unavailable) {
return;
}
event.preventDefault();
this.waiting = true;
this.load();
});
}
},
load() {
if (this.widgetId !== null || this.unavailable) {
return;
}
window.pkTurnstileReady ??= new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 10000);
window.pkTurnstileOnload = () => { clearTimeout(timer); resolve(window.turnstile); };
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=pkTurnstileOnload';
script.async = true;
script.onerror = () => { clearTimeout(timer); reject(new Error('load')); };
document.head.appendChild(script);
});
window.pkTurnstileReady.then((turnstile) => {
if (this.widgetId !== null) {
return;
}
this.widgetId = turnstile.render(this.$refs.box, {
sitekey: this.config.sitekey,
action: this.config.action,
language: this.config.language,
size: 'flexible',
'refresh-expired': 'auto',
callback: (token) => this.setToken(token),
'expired-callback': () => this.setToken(''),
'error-callback': (code) => {
if (! /^(300|600)/.test(String(code))) {
this.markUnavailable();
}
},
});
}).catch(() => {
window.pkTurnstileReady = null;
this.markUnavailable();
});
},
setToken(token) {
this.token = token;
if (this.config.wireToken) {
this.$wire.$set(this.config.wireToken, token, false);
}
if (token) {
this.submitPending();
}
},
markUnavailable() {
this.unavailable = true;
if (this.config.wireUnavailable) {
this.$wire.$set(this.config.wireUnavailable, true, false);
}
this.submitPending();
},
submitPending() {
if (! this.waiting) {
return;
}
this.waiting = false;
this.$nextTick(() => this.$el.closest('form')?.requestSubmit());
},
reset() {
this.setToken('');
if (this.widgetId !== null && window.turnstile) {
window.turnstile.reset(this.widgetId);
}
},
}"
@turnstile-reset.window="reset()"
{{ $attributes->class('min-h-[65px]') }}
>
<div x-ref="box"></div>
<input type="hidden" name="{{ \App\Services\Spam\TurnstileVerifier::UNAVAILABLE_FIELD }}" :value="unavailable ? '1' : ''" />
<p x-show="waiting" x-cloak class="mt-1 text-[11.5px] text-ink-3">{{ __('web.launcher.bot_check_waiting') }}</p>
</div>
@endif

View file

@ -163,6 +163,15 @@
<a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a>
</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>

View file

@ -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)]">

View file

@ -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') }}

View file

@ -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>

View file

@ -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>