From 7fa310fa76fa09622255033bfc8f7e30a37e702b Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 8 Oct 2026 11:41:24 +0000 Subject: [PATCH] security: Proxy-Vertrauen nur noch lokal, HSTS in Produktion, Tests ohne echte ntfy-Pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-Prüfung 08.10.2026: nginx beantwortet die Anfragen direkt (kein CDN, kein vorgeschalteter Proxy), die App vertraute aber jedem Proxy (trustProxies at '*'). Jeder Client konnte per X-Forwarded-For seine IP vorgeben und damit IP-Limits bei Login und Passwort-Reset umgehen sowie IP-Protokolle fälschen. Jetzt steuert config/trustedproxy.php das Vertrauen: lokal (Traefik) weiterhin '*', sonst keinem Proxy; ein späteres CDN kommt über TRUSTED_PROXIES dazu. HSTS fehlte auf Live. SecurityHeaders setzt Strict-Transport-Security auf HTTPS-Antworten, standardmäßig nur in Produktion (max-age 1 Jahr, bewusst ohne includeSubDomains/preload). phpunit.xml leert MONITORING_NTFY_*, damit Testläufe keine echten Push-Nachrichten mehr auslösen. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 ++++ app/Http/Middleware/SecurityHeaders.php | 5 +++ bootstrap/app.php | 8 ++-- config/security.php | 23 ++++++++++++ config/trustedproxy.php | 22 +++++++++++ phpunit.xml | 10 +++++ tests/Feature/SecurityHardeningTest.php | 49 +++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 config/security.php create mode 100644 config/trustedproxy.php diff --git a/.env.example b/.env.example index e5f3e016..73d3253c 100644 --- a/.env.example +++ b/.env.example @@ -346,3 +346,10 @@ IMAGE_PRECHECK=false IMAGE_PRECHECK_STAGE=1 # IMAGE_PRECHECK_MODEL= # IMAGE_PRECHECK_DOWNGRADE= + +# Proxy-Vertrauen: leer = keinem Proxy (Live, nginx direkt). Lokal ohne Angabe `*` (Traefik). +# Steht ein Proxy/CDN davor, dessen Adressbereiche kommagetrennt eintragen. +# TRUSTED_PROXIES= +# HSTS: ohne Angabe nur bei APP_ENV=production an, max-age 1 Jahr. +# SECURITY_HSTS= +# SECURITY_HSTS_MAX_AGE=31536000 diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index e01e3ad6..9f237846 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -10,6 +10,7 @@ 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 fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene * Inline-Script-Strategie — separat einführen, nicht nebenbei. */ @@ -30,6 +31,10 @@ class SecurityHeaders } } + 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; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index f175f80d..00135b26 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -118,16 +118,18 @@ return Application::configure(basePath: dirname(__DIR__)) // ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand // von config('auth.basic.*') (greift auch unter `config:cache`). $middleware->append(BasicAuthMiddleware::class); - // Trust all proxies (for Traefik/Docker setup) + // Welchen Proxies vertraut wird, steht in config/trustedproxy.php: + // lokal hinter Traefik allen, auf Live (nginx direkt, kein Proxy) + // keinem — sonst könnte jeder Client per X-Forwarded-For seine IP + // fälschen und damit IP-Limits und Protokolle umgehen. $middleware->trustProxies( - at: '*', headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO ); - // Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird, + // Host-Whitelist: Wo Proxies inkl. X-Forwarded-Host vertraut wird, // muss die App fremde Host-Header verwerfen — sonst kann ein Client per // X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify- // Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten diff --git a/config/security.php b/config/security.php new file mode 100644 index 00000000..c0491f6a --- /dev/null +++ b/config/security.php @@ -0,0 +1,23 @@ + [ + 'enabled' => (bool) env('SECURITY_HSTS', env('APP_ENV') === 'production'), + 'max_age' => (int) env('SECURITY_HSTS_MAX_AGE', 31536000), + ], + +]; diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 00000000..9667af07 --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,22 @@ + env('TRUSTED_PROXIES', env('APP_ENV') === 'local' ? '*' : null) ?: null, + +]; diff --git a/phpunit.xml b/phpunit.xml index 66fcaecd..35fa9bcb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -41,6 +41,16 @@ + + + + + + + + + + diff --git a/tests/Feature/SecurityHardeningTest.php b/tests/Feature/SecurityHardeningTest.php index b922cf97..dc0d3e44 100644 --- a/tests/Feature/SecurityHardeningTest.php +++ b/tests/Feature/SecurityHardeningTest.php @@ -4,6 +4,7 @@ use App\Models\Company; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; @@ -99,3 +100,51 @@ test('company create rejects svg logos', function () { expect(Company::query()->where('name', 'SVG GmbH')->exists())->toBeFalse(); }); + +test('https responses carry hsts only when it is enabled', function () { + /** @var TestCase $this */ + $this->get('https://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security'); + + config()->set('security.hsts', ['enabled' => true, 'max_age' => 31536000]); + + $this->get('https://presseecho.test/de')->assertHeader('Strict-Transport-Security', 'max-age=31536000'); + $this->get('http://presseecho.test/de')->assertHeaderMissing('Strict-Transport-Security'); +}); + +test('forwarded client ips are ignored unless the proxy is trusted', function () { + /** @var TestCase $this */ + Route::get('/_test/client-ip', fn () => request()->ip()); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) + ->withHeader('X-Forwarded-For', '198.51.100.7') + ->get('/_test/client-ip') + ->assertContent('203.0.113.10'); + + config()->set('trustedproxy.proxies', '203.0.113.10'); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) + ->withHeader('X-Forwarded-For', '198.51.100.7') + ->get('/_test/client-ip') + ->assertContent('198.51.100.7'); +}); + +test('production trusts no proxy unless one is configured', function (string $appEnv, ?string $trustedProxies, ?string $expected) { + $code = 'require "vendor/autoload.php"; echo json_encode((require "config/trustedproxy.php")["proxies"]);'; + + $result = Process::path(base_path()) + ->env(array_filter(['APP_ENV' => $appEnv, 'TRUSTED_PROXIES' => $trustedProxies], fn ($value) => $value !== null) + ['TRUSTED_PROXIES' => false]) + ->run([PHP_BINARY, '-r', $code]); + + expect(json_decode($result->throw()->output()))->toBe($expected); +})->with([ + 'live without setting' => ['production', null, null], + 'local behind traefik' => ['local', null, '*'], + 'live behind a cdn' => ['production', '10.0.0.0/8', '10.0.0.0/8'], + 'explicitly empty' => ['local', '', null], +]); + +test('the test suite never sends real ntfy pushes', function () { + expect(config('monitoring.alerts.ntfy.url'))->toBeEmpty() + ->and(config('monitoring.alerts.ntfy.topic'))->toBeEmpty() + ->and(config('monitoring.alerts.ntfy.token'))->toBeEmpty(); +});