presseportale/app/Support/ContentSecurityPolicy.php
Kevin Adametz 06d2889a4d 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>
2026-10-09 08:49:58 +00:00

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'] : '');
}
}