From 32f2f1431b878ed7272f346502af9caae39dd96d Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Sun, 2 Aug 2026 17:23:35 +0200 Subject: [PATCH] add TrustHosts middleware with exemption for healthchecks --- app/Http/Kernel.php | 2 + app/Http/Middleware/TrustHosts.php | 57 +++++++++++++ config/app.php | 21 +++++ tests/Feature/TrustHostsTest.php | 124 +++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 app/Http/Middleware/TrustHosts.php create mode 100644 tests/Feature/TrustHostsTest.php diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 4d9af16b..029aacad 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\ShareInertiaData; use App\Http\Middleware\TrimStrings; +use App\Http\Middleware\TrustHosts; use App\Http\Middleware\TrustProxies; use App\Http\Middleware\ValidateSignature; use App\Http\Middleware\VerifyCsrfToken; @@ -48,6 +49,7 @@ class Kernel extends HttpKernel protected $middleware = [ ForceHttps::class, TrustProxies::class, + TrustHosts::class, HandleCors::class, PreventRequestsDuringMaintenance::class, ValidatePostSize::class, diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 00000000..ab9f785a --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,57 @@ + + */ + public function hosts() + { + /** @var array $configured */ + $configured = config('app.trusted_hosts', []); + + $extra = array_map(function (string $host): string { + $host = trim($host); + + // "*.example.com" matches any subdomain, not the apex. + if (str_starts_with($host, '*.')) { + return '^.+\.'.preg_quote(substr($host, 2), '#').'$'; + } + + return '^'.preg_quote($host, '#').'$'; + }, $configured); + + return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra); + } + + /** + * @param \Closure(Request): Response $next + * @return Response + */ + public function handle(Request $request, $next) + { + // Exempt health checks (probed by IP). Also reset the trusted hosts, + // since Octane leaks the static state across requests. + if ($request->is('health-check/*')) { + Request::setTrustedHosts([]); + + return $next($request); + } + + return parent::handle($request, $next); + } +} diff --git a/config/app.php b/config/app.php index 9c72624a..356fecf1 100644 --- a/config/app.php +++ b/config/app.php @@ -75,6 +75,27 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + /* + |-------------------------------------------------------------------------- + | Trusted Hosts + |-------------------------------------------------------------------------- + | + | Additional hostnames (besides the APP_URL host and its subdomains) that + | the application is allowed to respond on. This is needed for multi-host + | setups, e.g. reaching the instance over both a public domain and a + | Tailscale name. A request arriving on any host that is neither APP_URL + | (nor a subdomain of it) nor listed here is rejected, which prevents + | Host-header poisoning of password reset and other out-of-band links. + | + | See App\Http\Middleware\TrustHosts. + | + */ + + 'trusted_hosts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('TRUSTED_HOSTS', '')) + ))), + 'asset_url' => env('ASSET_URL'), 'force_https' => (bool) env('APP_FORCE_HTTPS', false), diff --git a/tests/Feature/TrustHostsTest.php b/tests/Feature/TrustHostsTest.php new file mode 100644 index 00000000..3e0e8862 --- /dev/null +++ b/tests/Feature/TrustHostsTest.php @@ -0,0 +1,124 @@ + self::CANONICAL]); + } + + protected function tearDown(): void + { + Request::setTrustedHosts([]); // don't leak static state between tests + parent::tearDown(); + } + + /** + * The real middleware, with only the environment gate forced on (it + * self-exempts in the testing environment). + */ + private function middleware(): TrustHosts + { + return new class($this->app) extends TrustHosts + { + protected function shouldSpecifyTrustedHosts(): bool + { + return true; + } + }; + } + + private function accepts(Request $request): bool + { + $this->middleware()->handle($request, fn (): string => 'passed'); + + try { + $request->getHost(); + + return true; + } catch (\Throwable) { + return false; + } + } + + public function test_canonical_host_is_accepted(): void + { + $this->assertTrue($this->accepts(Request::create(self::CANONICAL.'/login'))); + } + + public function test_subdomain_of_canonical_host_is_accepted(): void + { + $this->assertTrue($this->accepts(Request::create('https://team.app.example.com/login'))); + } + + public function test_declared_trusted_host_is_accepted(): void + { + config(['app.trusted_hosts' => ['box.tailnet.ts.net']]); + + $this->assertTrue($this->accepts(Request::create('https://box.tailnet.ts.net/login'))); + } + + public function test_wildcard_trusted_host_matches_subdomains_only(): void + { + config(['app.trusted_hosts' => ['*.example.net']]); + + $this->assertTrue($this->accepts(Request::create('https://foo.example.net/login'))); + $this->assertTrue($this->accepts(Request::create('https://a.b.example.net/login'))); + // The apex is not matched by the wildcard, and suffix-injection is rejected. + $this->assertFalse($this->accepts(Request::create('https://example.net/login'))); + $this->assertFalse($this->accepts(Request::create('https://example.net.evil.com/login'))); + } + + public function test_multiple_trusted_hosts_are_all_accepted(): void + { + config(['app.trusted_hosts' => [ + 'box.tailnet.ts.net', + 'solidtime.internal', + '*.preview.example.com', + ]]); + + $this->assertTrue($this->accepts(Request::create('https://box.tailnet.ts.net/login'))); + $this->assertTrue($this->accepts(Request::create('https://solidtime.internal/login'))); + $this->assertTrue($this->accepts(Request::create('https://pr-42.preview.example.com/login'))); + // A host that is not listed is still rejected. + $this->assertFalse($this->accepts(Request::create('https://evil.example.com/login'))); + } + + public function test_poisoned_host_is_rejected(): void + { + $this->assertFalse($this->accepts(Request::create('https://evil.example.com/login'))); + } + + public function test_poisoned_x_forwarded_host_is_rejected(): void + { + $request = Request::create(self::CANONICAL.'/login'); + $request->headers->set('X-Forwarded-Host', 'evil.example.com'); + $request->setTrustedProxies( + ['0.0.0.0/0', '2000::/3'], + Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT + ); + + // getHost() now resolves to the poisoned X-Forwarded-Host value. + $this->assertFalse($this->accepts($request)); + } + + public function test_health_check_endpoint_bypasses_host_validation(): void + { + // Probed on internal hosts/IPs; must not be rejected. + $this->assertTrue($this->accepts(Request::create('https://0.0.0.0/health-check/up'))); + $this->assertTrue($this->accepts(Request::create('http://localhost/health-check/up'))); + } +}