From 19717ae515c654a5212bb77c29e9cd9f8bd536bf Mon Sep 17 00:00:00 2001 From: kingpy-bot Date: Sat, 11 Jul 2026 22:10:57 +0800 Subject: [PATCH] Reject dot-segment traversal paths --- src/Http/Controllers/PassageController.php | 22 +++++++++++++++ tests/Unit/PassageControllerTest.php | 31 +++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/PassageController.php b/src/Http/Controllers/PassageController.php index d090641..74b371c 100644 --- a/src/Http/Controllers/PassageController.php +++ b/src/Http/Controllers/PassageController.php @@ -55,6 +55,10 @@ public function handle(Request $request): Response return response()->json(['error' => 'Route not found'], Response::HTTP_NOT_FOUND); } + if ($this->containsDotSegment($path)) { + return response()->json(['error' => 'Invalid path'], Response::HTTP_BAD_REQUEST); + } + $handlerInstance = $this->app->make($handler); $mergedOptions = array_merge(config('passage.options', []), $handlerInstance->getOptions()); @@ -175,4 +179,22 @@ private function fireEvent(object $event): void Event::dispatch($event); } } + + private function containsDotSegment(string $path): bool + { + $decoded = str_replace('\\', '/', $path); + + do { + foreach (explode('/', $decoded) as $segment) { + if ($segment === '.' || $segment === '..') { + return true; + } + } + + $previous = $decoded; + $decoded = rawurldecode($decoded); + } while ($decoded !== $previous); + + return false; + } } diff --git a/tests/Unit/PassageControllerTest.php b/tests/Unit/PassageControllerTest.php index d3d7bf4..b28c899 100644 --- a/tests/Unit/PassageControllerTest.php +++ b/tests/Unit/PassageControllerTest.php @@ -243,8 +243,8 @@ function jsonUpstreamResponse(array $data, int $status = 200): Response $this->controller->handle($request); }); - it('extracts the path route parameter as the forwarded URI', function () { - $request = Request::create('/github/users/morcen/repos', 'GET'); + it('extracts a safe path with dots as the forwarded URI', function () { + $request = Request::create('/github/v1.2/.well-known/file..name', 'GET'); $route = (new Route(['GET'], '/github/{path?}', [])) ->defaults('_passage_handler', TestPassageController::class) ->where('path', '.*'); @@ -258,7 +258,7 @@ function jsonUpstreamResponse(array $data, int $status = 200): Response $this->mockPassageService->shouldReceive('callService') ->withArgs(function (Request $req, $pending, string $uri) { - return $uri === 'users/morcen/repos'; + return $uri === 'v1.2/.well-known/file..name'; }) ->once() ->andReturn($mockResponse); @@ -266,6 +266,31 @@ function jsonUpstreamResponse(array $data, int $status = 200): Response $this->controller->handle($request); }); + it('rejects dot-segment traversal before creating an upstream request', function (string $path) { + $request = Request::create('/github/safe', 'GET'); + $route = (new Route(['GET'], '/github/{path?}', [])) + ->defaults('_passage_handler', TestPassageController::class) + ->where('path', '.*'); + $route->bind($request); + $route->setParameter('path', $path); + $request->setRouteResolver(fn () => $route); + + Http::shouldReceive('withOptions')->never(); + $this->mockPassageService->shouldReceive('callService')->never(); + + $response = $this->controller->handle($request); + + expect($response->getStatusCode())->toBe(ResponseCode::HTTP_BAD_REQUEST); + expect($response->getData(true))->toBe(['error' => 'Invalid path']); + })->with([ + 'literal parent segment' => '../private', + 'nested parent segment' => 'public/../private', + 'percent-encoded parent segment' => '%2e%2e/private', + 'mixed-encoded parent segment' => '%2E./private', + 'encoded separator after parent segment' => '%2e%2e%2fprivate', + 'double-encoded parent segment' => '%252e%252e/private', + ]); + it('applies response transformation', function () { $request = Request::create('/enriched/posts', 'GET'); $route = (new Route(['GET'], '/enriched/{path?}', []))