presseportale/app/Http/Controllers/CspReportController.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

65 lines
2.3 KiB
PHP

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Nimmt CSP-Meldungen der Browser entgegen (ContentSecurityPolicy, Melde-
* Modus) und schreibt sie nach storage/logs/csp-*.log. Gleiche Meldungen
* landen höchstens einmal pro Stunde im Log. Adressen werden ohne Query und
* ohne tokenartige Pfadsegmente gespeichert — signierte Links und
* Anmeldelinks gehören nicht ins Log.
*/
class CspReportController extends Controller
{
private const MAX_BYTES = 16384;
public function __invoke(Request $request): Response
{
$body = $request->getContent();
if (! config('security.csp.report_only') || $body === '' || strlen($body) > self::MAX_BYTES) {
return response()->noContent();
}
$report = json_decode($body, true)['csp-report'] ?? null;
if (! is_array($report)) {
return response()->noContent();
}
$entry = [
'directive' => mb_substr((string) ($report['effective-directive'] ?? $report['violated-directive'] ?? ''), 0, 60),
'blocked' => $this->scrub($report['blocked-uri'] ?? ''),
'document' => $this->scrub($report['document-uri'] ?? ''),
'source' => $this->scrub($report['source-file'] ?? ''),
'line' => (int) ($report['line-number'] ?? 0),
'sample' => mb_substr((string) ($report['script-sample'] ?? ''), 0, 40),
];
if (Cache::add('csp-report.'.sha1(json_encode($entry)), true, now()->addHour())) {
Log::channel('csp')->warning('CSP-Verstoß (nur gemeldet)', $entry);
}
return response()->noContent();
}
private function scrub(mixed $value): string
{
$value = mb_substr(is_string($value) ? $value : '', 0, 500);
$parts = parse_url($value);
// Schlüsselwörter wie „inline“, „eval“, „data“ unverändert.
if (! is_array($parts) || empty($parts['host'])) {
return mb_substr(strtok($value, '?#') ?: '', 0, 60);
}
$path = preg_replace('#/[A-Za-z0-9_-]{32,}(?=/|$)#', '/[Filtered]', $parts['path'] ?? '');
return mb_substr(($parts['scheme'] ?? 'https').'://'.$parts['host'].$path, 0, 200);
}
}