Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026: - Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer Impersonation). - LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten. Gezählt über das Failed-Event, damit Livewire-Login und Fortifys POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück. - Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise mit und kann im nächsten Release entfallen. - Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig. - Content-Security-Policy-Report-Only in Produktion; Meldungen über /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro Stunde, ohne Query und tokenartige Pfadsegmente. Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
2.8 KiB
PHP
73 lines
2.8 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'));
|
|
|
|
$directives = [
|
|
'default-src' => ["'self'"],
|
|
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami],
|
|
'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-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'] : '');
|
|
}
|
|
}
|