From 881533baa55e38fada050a33419293f8a73a8d3b Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 8 Jul 2026 14:42:12 +0100 Subject: [PATCH 1/2] Harden against host header injection and open redirect Heimdall trusted the incoming X-Forwarded-Host header for URL generation, so a spoofed value poisoned the page base href, asset() URLs and redirect targets - loading assets from and redirecting to an attacker-controlled host (CVE-2025-50578). - TrustProxies no longer trusts X-Forwarded-Host; a forged value can no longer influence getHost(), url(), asset() or redirects. X-Forwarded-For/Port/Proto handling is unchanged. - Trusted proxies are now configurable via the TRUSTED_PROXIES env var (comma-separated CIDRs/IPs, "*" to trust all), defaulting to the previous private ranges. - Added an opt-in TRUSTED_HOSTS allow-list: when set, only the listed hosts are served and any other Host header is rejected. Unset keeps the historic behaviour of serving arbitrary hosts, so existing installs are unaffected. Resolves #1451 --- .env.example | 10 +++ app/Http/Middleware/TrustHosts.php | 41 ++++++++++ app/Http/Middleware/TrustProxies.php | 44 ++++++++++- bootstrap/app.php | 3 + tests/Feature/TrustHostsTest.php | 108 +++++++++++++++++++++++++++ tests/Feature/TrustProxiesTest.php | 108 +++++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 app/Http/Middleware/TrustHosts.php create mode 100644 tests/Feature/TrustHostsTest.php create mode 100644 tests/Feature/TrustProxiesTest.php 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..f4190332d --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,41 @@ +|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..efa297f39 --- /dev/null +++ b/tests/Feature/TrustHostsTest.php @@ -0,0 +1,108 @@ +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); + } +} 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()); + } +} From 9a9877a0dc264a908c161d96898864df88014868 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 8 Jul 2026 18:22:49 +0100 Subject: [PATCH 2/2] Enforce TRUSTED_HOSTS allow-list regardless of APP_ENV The custom TrustHosts middleware only overrode hosts(), so it inherited the parent's shouldSpecifyTrustedHosts() gate, which skips enforcement whenever the app runs in the local environment or under the test runner. Heimdall ships APP_ENV=local by default (.env.example, copied to .env on install), so the TRUSTED_HOSTS allow-list a user configures per the .env.example guidance was never actually applied. Override shouldSpecifyTrustedHosts() to tie enforcement to configuration instead of environment: apply the allow-list whenever TRUSTED_HOSTS is set, in any environment; when it is unset hosts() is empty and enforcement stays off, preserving the historic no-restriction behaviour. Add handle()-driven tests covering both the configured and unconfigured cases. --- app/Http/Middleware/TrustHosts.php | 19 +++++++++++++++ tests/Feature/TrustHostsTest.php | 38 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index f4190332d..cde8d8f43 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -38,4 +38,23 @@ public function hosts() return $hosts; } + + /** + * Determine if the application should specify trusted hosts. + * + * The parent implementation skips enforcement whenever the app runs in the + * "local" environment (Heimdall's shipped default, see .env.example) or + * under the test runner, which would leave the TRUSTED_HOSTS allow-list + * silently unenforced for almost every real deployment. Instead we tie + * enforcement directly to configuration: apply the allow-list whenever one + * has actually been provided, in any environment. When TRUSTED_HOSTS is + * unset hosts() is empty and this returns false, preserving the historic + * no-restriction behaviour. + * + * @return bool + */ + protected function shouldSpecifyTrustedHosts() + { + return ! empty($this->hosts()); + } } diff --git a/tests/Feature/TrustHostsTest.php b/tests/Feature/TrustHostsTest.php index efa297f39..afc1b51e3 100644 --- a/tests/Feature/TrustHostsTest.php +++ b/tests/Feature/TrustHostsTest.php @@ -105,4 +105,42 @@ public function test_custom_trust_hosts_middleware_is_registered_globally(): voi $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()); + } }