presseportale/app/Support/ContentSecurityPolicy.php
Kevin Adametz 5dfd622141 security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus
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>
2026-10-08 13:02:49 +00:00

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