- 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>
75 lines
2.9 KiB
PHP
75 lines
2.9 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
/**
|
|
* Content Security Policy, vorerst nur im Melde-Modus
|
|
* (`Content-Security-Policy-Report-Only`, Sicherheitsaudit 07.10.2026,
|
|
* Phase 4). Browser blockieren nichts, sondern melden Verstöße an
|
|
* `/csp-report` (storage/logs/csp-*.log). Erst wenn die Meldungen zeigen,
|
|
* dass die Liste vollständig ist, wird sie scharf geschaltet.
|
|
*
|
|
* `'unsafe-inline'` und `'unsafe-eval'` sind für Livewire/Alpine und die
|
|
* Inline-Skripte der Layouts vorerst nötig; die Policy begrenzt daher vor
|
|
* allem, von wo Skripte geladen und wohin Daten geschickt werden dürfen,
|
|
* sowie Einbettung, Formularziele und `<base>`.
|
|
*/
|
|
class ContentSecurityPolicy
|
|
{
|
|
public const REPORT_PATH = '/csp-report';
|
|
|
|
public static function header(): string
|
|
{
|
|
$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, $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'", $turnstile],
|
|
'frame-ancestors' => ["'self'"],
|
|
'base-uri' => ["'self'"],
|
|
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],
|
|
'report-uri' => [self::REPORT_PATH],
|
|
];
|
|
|
|
return collect($directives)
|
|
->map(fn (array $sources, string $directive): string => trim($directive.' '.implode(' ', array_unique(array_filter($sources)))))
|
|
->implode('; ');
|
|
}
|
|
|
|
/**
|
|
* Die drei Domains der Anwendung — Formulare und Assets können
|
|
* domainübergreifend auf die anderen zeigen.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private static function ownOrigins(): array
|
|
{
|
|
return collect([
|
|
config('domains.domain_portal'),
|
|
config('domains.domain_presseecho'),
|
|
config('domains.domain_businessportal'),
|
|
])->filter()->map(fn (string $host): string => 'https://'.$host)->values()->all();
|
|
}
|
|
|
|
private static function originOf(string $url): ?string
|
|
{
|
|
$parts = parse_url($url);
|
|
|
|
if (! is_array($parts) || empty($parts['host'])) {
|
|
return null;
|
|
}
|
|
|
|
return ($parts['scheme'] ?? 'https').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
|
|
}
|
|
}
|