From 80d98b30a19d81bd0aa0933ace827dd73adc4254 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Sun, 2 Aug 2026 18:09:46 +0200 Subject: [PATCH] add custom error handling for host mismatch; ensure TrustHosts runs before TrustProxies --- app/Exceptions/Handler.php | 26 ++++++++++ app/Http/Kernel.php | 2 +- .../views/errors/untrusted-host.blade.php | 49 +++++++++++++++++++ tests/Feature/TrustHostsTest.php | 41 +++++++++++++++- 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 resources/views/errors/untrusted-host.blade.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index b20f9475..fad5f597 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -6,7 +6,10 @@ namespace App\Exceptions; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Throwable; class Handler extends ExceptionHandler @@ -30,6 +33,29 @@ class Handler extends ExceptionHandler $this->reportable(function (Throwable $e): void { // }); + + // A request on an untrusted host (see App\Http\Middleware\TrustHosts) + // otherwise renders as a bare "Bad request." 400. Show a message that + // says how to fix it instead. The framework has already converted the + // SuspiciousOperationException into a BadRequestHttpException by the time + // renderables run, so we match that and inspect the original. + $this->renderable(function (BadRequestHttpException $e, Request $request): ?Response { + $previous = $e->getPrevious(); + + if (! $previous instanceof SuspiciousOperationException + || ! str_starts_with($previous->getMessage(), 'Untrusted Host')) { + return null; // any other bad request keeps the default response + } + + $message = 'This hostname is not configured for this instance. ' + .'Set APP_URL, or add the host to TRUSTED_HOSTS.'; + + if ($request->expectsJson()) { + return response()->json(['message' => $message], 400); + } + + return response()->view('errors.untrusted-host', ['message' => $message], 400); + }); } public function render($request, Throwable $e): Response|RedirectResponse diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 029aacad..7ffd7f2f 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -48,8 +48,8 @@ class Kernel extends HttpKernel */ protected $middleware = [ ForceHttps::class, - TrustProxies::class, TrustHosts::class, + TrustProxies::class, HandleCors::class, PreventRequestsDuringMaintenance::class, ValidatePostSize::class, diff --git a/resources/views/errors/untrusted-host.blade.php b/resources/views/errors/untrusted-host.blade.php new file mode 100644 index 00000000..2da2cfde --- /dev/null +++ b/resources/views/errors/untrusted-host.blade.php @@ -0,0 +1,49 @@ +{{-- Self-contained on purpose: this page is rendered for a request on an + untrusted host, so it must not call url()/route()/asset(), which would + re-trigger Host validation and throw again. --}} + + + + + + Untrusted host + + + +
+

Untrusted host

+

+ This hostname is not configured for this instance. Set + APP_URL, or add the host to TRUSTED_HOSTS. +

+
+ + diff --git a/tests/Feature/TrustHostsTest.php b/tests/Feature/TrustHostsTest.php index 3e0e8862..2a486f7f 100644 --- a/tests/Feature/TrustHostsTest.php +++ b/tests/Feature/TrustHostsTest.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace Tests\Feature; use App\Http\Middleware\TrustHosts; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; +use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; class TrustHostsTest extends TestCase @@ -42,7 +45,7 @@ class TrustHostsTest extends TestCase private function accepts(Request $request): bool { - $this->middleware()->handle($request, fn (): string => 'passed'); + $this->middleware()->handle($request, fn (Request $request): Response => new Response('passed')); try { $request->getHost(); @@ -121,4 +124,40 @@ class TrustHostsTest extends TestCase $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'))); } + + public function test_health_check_endpoint_clears_state_before_other_middleware_reads_the_host(): void + { + // Simulate trusted-host state left by a previous request in an Octane worker. + Request::setTrustedHosts(['^app\.example\.com$']); + + $this->get(self::CANONICAL.'/health-check/up', ['Host' => '0.0.0.0']) + ->assertSuccessful() + ->assertExactJson(['success' => true]); + } + + public function test_untrusted_host_renders_a_helpful_error(): void + { + $handler = app(ExceptionHandler::class); + $exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".'); + + $response = $handler->render(Request::create('https://evil.example.com/login'), $exception); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent()); + } + + public function test_untrusted_host_returns_json_for_api_clients(): void + { + $handler = app(ExceptionHandler::class); + $exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".'); + + $request = Request::create('https://evil.example.com/api/v1/users'); + $request->headers->set('Accept', 'application/json'); + + $response = $handler->render($request, $exception); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertJson((string) $response->getContent()); + $this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent()); + } }