diff --git a/.env.example b/.env.example index b50232a52..6764eb03f 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,16 @@ APP_KEY= APP_DEBUG=false APP_URL=http://localhost +# Security: Host Header Injection / Open Redirect hardening (CVE-2025-50578). +# TRUSTED_PROXIES: comma-separated CIDRs/IPs of reverse proxies allowed to set +# X-Forwarded-* headers. Defaults to the private ranges below when unset. Use +# "*" to trust all proxies (only behind a trusted network boundary). +#TRUSTED_PROXIES=192.168.0.0/16,172.16.0.0/12,10.0.0.0/8,127.0.0.1 +# TRUSTED_HOSTS: comma-separated hostnames Heimdall is allowed to serve. Unset +# means no restriction (default, backward compatible). Set this to your own +# domain to fully prevent host-header injection / open redirects. +#TRUSTED_HOSTS=heimdall.example.com + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 000000000..cde8d8f43 --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,60 @@ +hosts()); + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 95e5ccd77..0de5aa1c5 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -8,16 +8,52 @@ class TrustProxies extends Middleware { /** - * The trusted proxies for this application. + * The default trusted proxies used when the TRUSTED_PROXIES env var is unset. * * @var array */ - protected $proxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1']; + protected $defaultProxies = ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1']; + + /** + * The trusted proxies for this application. + * + * @var array|string|null + */ + protected $proxies; /** * The current proxy header mappings. * - * @var array + * Note: Request::HEADER_X_FORWARDED_HOST is intentionally NOT trusted to + * prevent Host header injection / open redirects (CVE-2025-50578). A spoofed + * X-Forwarded-Host header must never influence getHost()/url()/asset(). + * + * @var int */ - protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Create a new middleware instance. + * + * The set of trusted proxies is read from the TRUSTED_PROXIES env var + * (comma-separated CIDRs/IPs). When unset it falls back to the historic + * default list. The special value "*" trusts all proxies. + * + * @return void + */ + public function __construct() + { + $trustedProxies = env('TRUSTED_PROXIES'); + + if ($trustedProxies === null || trim((string) $trustedProxies) === '') { + $this->proxies = $this->defaultProxies; + } elseif (trim((string) $trustedProxies) === '*') { + $this->proxies = '*'; + } else { + $this->proxies = array_values(array_filter( + array_map('trim', explode(',', (string) $trustedProxies)), + fn ($proxy) => $proxy !== '' + )); + } + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 1b6473e0b..4dcefb18c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -32,6 +32,9 @@ $middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class); + $middleware->trustHosts(); + $middleware->replace(\Illuminate\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustHosts::class); + $middleware->alias([ 'allowed' => \App\Http\Middleware\CheckAllowed::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, diff --git a/tests/Feature/TrustHostsTest.php b/tests/Feature/TrustHostsTest.php new file mode 100644 index 000000000..afc1b51e3 --- /dev/null +++ b/tests/Feature/TrustHostsTest.php @@ -0,0 +1,146 @@ +app->make(TrustHosts::class); + } + + public function test_hosts_is_empty_when_env_unset(): void + { + putenv('TRUSTED_HOSTS'); + unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']); + + $this->assertSame([], $this->makeMiddleware()->hosts()); + } + + public function test_arbitrary_host_is_accepted_when_env_unset(): void + { + putenv('TRUSTED_HOSTS'); + unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']); + + // No trusted host patterns configured -> getHost() must not throw. + Request::setTrustedHosts(array_filter($this->makeMiddleware()->hosts())); + + $request = Request::create('http://anything.example/', 'GET'); + + $this->assertSame('anything.example', $request->getHost()); + } + + public function test_hosts_contains_pattern_matching_configured_host(): void + { + $this->setTrustedHostsEnv('example.com'); + + $hosts = $this->makeMiddleware()->hosts(); + + $this->assertNotEmpty($hosts); + $this->assertCount(1, $hosts); + // Symfony wraps each pattern as {pattern}i before matching. + $this->assertSame(1, preg_match('{'.$hosts[0].'}i', 'example.com')); + $this->assertSame(0, preg_match('{'.$hosts[0].'}i', 'evil.com')); + } + + public function test_configured_host_is_accepted_and_others_rejected(): void + { + $this->setTrustedHostsEnv('example.com'); + + Request::setTrustedHosts($this->makeMiddleware()->hosts()); + + $accepted = Request::create('http://example.com/', 'GET'); + $this->assertSame('example.com', $accepted->getHost()); + + $this->expectException(SuspiciousOperationException::class); + + Request::create('http://evil.com/', 'GET')->getHost(); + } + + public function test_multiple_hosts_can_be_configured(): void + { + $this->setTrustedHostsEnv('example.com, dash.example.org'); + + $hosts = $this->makeMiddleware()->hosts(); + + $this->assertCount(2, $hosts); + + Request::setTrustedHosts($hosts); + + $this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost()); + $this->assertSame('dash.example.org', Request::create('http://dash.example.org/', 'GET')->getHost()); + } + + public function test_custom_trust_hosts_middleware_is_registered_globally(): void + { + $globalMiddleware = $this->app->make(Kernel::class)->getGlobalMiddleware(); + + $this->assertContains(TrustHosts::class, $globalMiddleware); + $this->assertNotContains(\Illuminate\Http\Middleware\TrustHosts::class, $globalMiddleware); + } + + public function test_handle_enforces_trusted_hosts_even_in_local_environment(): void + { + // The app runs as APP_ENV=local under the test runner; the parent + // middleware would skip enforcement entirely. Confirm handle() still + // applies the allow-list once TRUSTED_HOSTS is configured. + $this->setTrustedHostsEnv('example.com'); + + $request = Request::create('http://example.com/', 'GET'); + + $reachedNext = false; + $this->makeMiddleware()->handle($request, function ($req) use (&$reachedNext) { + $reachedNext = true; + + return $req; + }); + + $this->assertTrue($reachedNext); + + // The configured host is now accepted and any other Host is rejected. + $this->assertSame('example.com', Request::create('http://example.com/', 'GET')->getHost()); + + $this->expectException(SuspiciousOperationException::class); + Request::create('http://evil.com/', 'GET')->getHost(); + } + + public function test_handle_does_not_restrict_hosts_when_env_unset(): void + { + putenv('TRUSTED_HOSTS'); + unset($_ENV['TRUSTED_HOSTS'], $_SERVER['TRUSTED_HOSTS']); + + $request = Request::create('http://anything.example/', 'GET'); + + $this->makeMiddleware()->handle($request, fn ($req) => $req); + + // No allow-list configured -> arbitrary hosts still accepted. + $this->assertSame('anything.example', Request::create('http://anything.example/', 'GET')->getHost()); + } +} diff --git a/tests/Feature/TrustProxiesTest.php b/tests/Feature/TrustProxiesTest.php new file mode 100644 index 000000000..bc06ecd70 --- /dev/null +++ b/tests/Feature/TrustProxiesTest.php @@ -0,0 +1,108 @@ + $this->{$property})->call($object); + } + + public function test_x_forwarded_host_header_is_ignored(): void + { + $request = Request::create('http://localhost/', 'GET'); + $request->server->set('REMOTE_ADDR', '10.0.0.1'); + $request->headers->set('X-Forwarded-Host', 'evil.com'); + $request->server->set('HTTP_X_FORWARDED_HOST', 'evil.com'); + + (new TrustProxies())->handle($request, fn ($req) => $req); + + $this->assertSame('localhost', $request->getHost()); + $this->assertNotSame('evil.com', $request->getHost()); + } + + public function test_headers_bitmask_excludes_forwarded_host(): void + { + $headers = $this->readProtected(new TrustProxies(), 'headers'); + + $this->assertSame(0, $headers & Request::HEADER_X_FORWARDED_HOST); + $this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_FOR); + $this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PORT); + $this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_PROTO); + $this->assertNotSame(0, $headers & Request::HEADER_X_FORWARDED_AWS_ELB); + } + + public function test_default_trusted_proxies_when_env_unset(): void + { + putenv('TRUSTED_PROXIES'); + unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']); + + $proxies = $this->readProtected(new TrustProxies(), 'proxies'); + + $this->assertSame( + ['192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8', '127.0.0.1'], + $proxies + ); + } + + public function test_trusted_proxies_can_be_configured_via_env(): void + { + $this->setTrustedProxiesEnv('203.0.113.5, 198.51.100.0/24'); + + $proxies = $this->readProtected(new TrustProxies(), 'proxies'); + + $this->assertSame(['203.0.113.5', '198.51.100.0/24'], $proxies); + } + + public function test_trusted_proxies_supports_wildcard(): void + { + $this->setTrustedProxiesEnv('*'); + + $proxies = $this->readProtected(new TrustProxies(), 'proxies'); + + $this->assertSame('*', $proxies); + } + + public function test_wildcard_proxy_trusts_calling_ip_for_forwarded_headers(): void + { + $this->setTrustedProxiesEnv('*'); + + $request = Request::create('http://localhost/', 'GET'); + $request->server->set('REMOTE_ADDR', '203.0.113.9'); + $request->headers->set('X-Forwarded-Proto', 'https'); + $request->server->set('HTTP_X_FORWARDED_PROTO', 'https'); + + (new TrustProxies())->handle($request, fn ($req) => $req); + + // Proto is honored (proxy trusted) but host is still not taken from headers. + $this->assertTrue($request->isSecure()); + $this->assertSame('localhost', $request->getHost()); + } +}