security: Proxy-Vertrauen nur noch lokal, HSTS in Produktion, Tests ohne echte ntfy-Pushes
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 <noreply@anthropic.com>
This commit is contained in:
parent
6eb7c08863
commit
7fa310fa76
7 changed files with 121 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
config/security.php
Normal file
23
config/security.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Strict Transport Security
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Weist Browser an, die Domain für `max_age` Sekunden nur noch per HTTPS
|
||||
| aufzurufen. Wird nur auf HTTPS-Antworten gesetzt und standardmäßig nur
|
||||
| in Produktion. Bewusst ohne includeSubDomains und preload: beides
|
||||
| lässt sich kaum zurücknehmen und betrifft auch fremd betriebene
|
||||
| Subdomains.
|
||||
|
|
||||
*/
|
||||
|
||||
'hsts' => [
|
||||
'enabled' => (bool) env('SECURITY_HSTS', env('APP_ENV') === 'production'),
|
||||
'max_age' => (int) env('SECURITY_HSTS_MAX_AGE', 31536000),
|
||||
],
|
||||
|
||||
];
|
||||
22
config/trustedproxy.php
Normal file
22
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Vertrauenswürdige Proxies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Nur Anfragen von diesen Adressen dürfen per X-Forwarded-* die Client-IP,
|
||||
| das Protokoll und den Host vorgeben. Lokal läuft die App hinter Traefik
|
||||
| (`*` = der aufrufende Container). Auf Live beantwortet nginx die
|
||||
| Anfragen direkt — dort bleibt die Liste leer, sonst könnte jeder Client
|
||||
| seine IP fälschen und IP-Limits beim Login oder Passwort-Reset umgehen.
|
||||
| Kommt einmal ein Proxy oder CDN davor: dessen Adressbereiche als
|
||||
| kommagetrennte Liste in TRUSTED_PROXIES eintragen.
|
||||
|
|
||||
*/
|
||||
|
||||
'proxies' => env('TRUSTED_PROXIES', env('APP_ENV') === 'local' ? '*' : null) ?: null,
|
||||
|
||||
];
|
||||
10
phpunit.xml
10
phpunit.xml
|
|
@ -41,6 +41,16 @@
|
|||
<server name="SENTRY_LARAVEL_DSN" value="" force="true"/>
|
||||
<server name="SENTRY_DSN" value="" force="true"/>
|
||||
<server name="SENTRY_BROWSER_DSN" value="" force="true"/>
|
||||
<!-- Betriebs-Alarme aus Tests nie als echte Push-Nachricht (ntfy) verschicken. -->
|
||||
<env name="MONITORING_NTFY_URL" value="" force="true"/>
|
||||
<env name="MONITORING_NTFY_TOPIC" value="" force="true"/>
|
||||
<env name="MONITORING_NTFY_TOKEN" value="" force="true"/>
|
||||
<server name="MONITORING_NTFY_URL" value="" force="true"/>
|
||||
<server name="MONITORING_NTFY_TOPIC" value="" force="true"/>
|
||||
<server name="MONITORING_NTFY_TOKEN" value="" force="true"/>
|
||||
<!-- Proxy-Vertrauen und HSTS testen die Tests selbst; Standard wie auf Live. -->
|
||||
<env name="TRUSTED_PROXIES" value="" force="true"/>
|
||||
<env name="SECURITY_HSTS" value="false" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue