From 168bb267982344fd9e20a30f2cedcb6ca04c0b69 Mon Sep 17 00:00:00 2001 From: Morris Jencen Chavez Date: Sat, 11 Jul 2026 18:15:21 +0000 Subject: [PATCH] fix: return JSON 403 for blocked proxy targets instead of crashing AllowedHostsGuard::check() throws DisallowedProxyTargetException when a handler's base_uri host is not in the allowed_hosts list, but PassageController only caught InvalidBaseUriException. A blocked host therefore surfaced as an unhandled 500 with a framework error page instead of a clean JSON response, leaking internals and breaking API clients that expect JSON errors from Passage. Catch DisallowedProxyTargetException alongside InvalidBaseUriException and return a JSON 403 response. --- src/Http/Controllers/PassageController.php | 3 ++ tests/Unit/PassageControllerTest.php | 41 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Http/Controllers/PassageController.php b/src/Http/Controllers/PassageController.php index d090641..ea1199c 100644 --- a/src/Http/Controllers/PassageController.php +++ b/src/Http/Controllers/PassageController.php @@ -12,6 +12,7 @@ use Morcen\Passage\Events\PassageRequestFailed; use Morcen\Passage\Events\PassageRequestSending; use Morcen\Passage\Events\PassageResponseReceived; +use Morcen\Passage\Exceptions\DisallowedProxyTargetException; use Morcen\Passage\Exceptions\InvalidBaseUriException; use Morcen\Passage\Exceptions\PassageRequestAbortedException; use Morcen\Passage\Guards\AllowedHostsGuard; @@ -66,6 +67,8 @@ public function handle(Request $request): Response $this->allowedHostsGuard->check($mergedOptions['base_uri']); } catch (InvalidBaseUriException $e) { return response()->json(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); + } catch (DisallowedProxyTargetException $e) { + return response()->json(['error' => $e->getMessage()], Response::HTTP_FORBIDDEN); } // Extract Passage reserved keys before passing options to Guzzle. diff --git a/tests/Unit/PassageControllerTest.php b/tests/Unit/PassageControllerTest.php index d3d7bf4..fcf498c 100644 --- a/tests/Unit/PassageControllerTest.php +++ b/tests/Unit/PassageControllerTest.php @@ -116,6 +116,25 @@ public function getOptions(): array } } +// Fixture: base_uri pointing at a host that can be disallowed via config +class TestDisallowedHostPassageController implements PassageControllerInterface +{ + public function getRequest(Request $request): Request + { + return $request; + } + + public function getResponse(Request $request, Response $response): Response + { + return $response; + } + + public function getOptions(): array + { + return ['base_uri' => 'https://api.evil.com/']; + } +} + beforeEach(function () { $this->mockPassageService = Mockery::mock(PassageServiceInterface::class); $this->app->instance(PassageServiceInterface::class, $this->mockPassageService); @@ -189,6 +208,28 @@ function jsonUpstreamResponse(array $data, int $status = 200): Response ]); }); + it('returns a JSON 403 when the base_uri host is not in the allowed_hosts list', function () { + config([ + 'passage.security.enforce_allowed_hosts' => true, + 'passage.security.allowed_hosts' => ['api.example.com'], + ]); + + $request = Request::create('/blocked-host/test', 'GET'); + $route = (new Route(['GET'], '/blocked-host/{path?}', [])) + ->defaults('_passage_handler', TestDisallowedHostPassageController::class); + $route->bind($request); + $request->setRouteResolver(fn () => $route); + + Http::shouldReceive('withOptions')->never(); + + $response = $this->controller->handle($request); + + expect($response->getStatusCode())->toBe(ResponseCode::HTTP_FORBIDDEN); + expect(json_decode($response->getContent(), true))->toBe([ + 'error' => 'Upstream host [api.evil.com] is not in the passage allowed_hosts list.', + ]); + }); + it('proxies a basic GET request and returns JSON response', function () { $request = Request::create('/github/users/123', 'GET'); $route = (new Route(['GET'], '/github/{path?}', []))