presseportale/app/Http/Middleware/SecurityHeaders.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

46 lines
1.7 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Support\ContentSecurityPolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* HSTS nur auf HTTPS-Antworten und laut config/security.php.
* CSP vorerst nur im Melde-Modus (ContentSecurityPolicy, config/security.php).
*/
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
foreach ([
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'SAMEORIGIN',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
] as $header => $value) {
if (! $response->headers->has($header)) {
$response->headers->set($header, $value);
}
}
if (config('security.csp.report_only')
&& str_contains((string) $response->headers->get('Content-Type'), 'text/html')
&& ! $response->headers->has('Content-Security-Policy-Report-Only')) {
$response->headers->set('Content-Security-Policy-Report-Only', ContentSecurityPolicy::header());
}
if ($request->isSecure() && config('security.hsts.enabled') && ! $response->headers->has('Strict-Transport-Security')) {
$response->headers->set('Strict-Transport-Security', 'max-age='.(int) config('security.hsts.max_age'));
}
return $response;
}
}