diff --git a/docs/en/middleware.rst b/docs/en/middleware.rst index 69fd86e..ff9cd32 100644 --- a/docs/en/middleware.rst +++ b/docs/en/middleware.rst @@ -198,6 +198,11 @@ Both redirect handlers share the same configuration options: * ``queryParam`` - the accessed request URL will be attached to the redirect URL query parameter (``redirect`` by default). * ``statusCode`` - HTTP status code of a redirect, ``302`` by default. +* ``allowedRedirectExtensions`` - an array of allowed file extensions for redirecting. + If the request URL has a file extension that is not in this list, the redirect will not + happen and the exception will be rethrown. Can also be a boolean to toggle on/off + redirects entirely. This is useful to prevent unauthorized access to API based + responses, that should not be redirecting in any case. `true` by default and not enabled then. For example:: @@ -212,6 +217,7 @@ For example:: MissingIdentityException::class, OtherException::class, ], + 'allowedRedirectExtensions' => ['csv', 'pdf'], ], ])); diff --git a/src/Middleware/UnauthorizedHandler/CakeRedirectHandler.php b/src/Middleware/UnauthorizedHandler/CakeRedirectHandler.php index e6318bf..e5ec4f7 100644 --- a/src/Middleware/UnauthorizedHandler/CakeRedirectHandler.php +++ b/src/Middleware/UnauthorizedHandler/CakeRedirectHandler.php @@ -41,6 +41,7 @@ class CakeRedirectHandler extends RedirectHandler ], 'queryParam' => 'redirect', 'statusCode' => 302, + 'allowedRedirectExtensions' => true, ]; /** diff --git a/src/Middleware/UnauthorizedHandler/RedirectHandler.php b/src/Middleware/UnauthorizedHandler/RedirectHandler.php index 8910b16..cd9498b 100644 --- a/src/Middleware/UnauthorizedHandler/RedirectHandler.php +++ b/src/Middleware/UnauthorizedHandler/RedirectHandler.php @@ -34,6 +34,8 @@ class RedirectHandler implements HandlerInterface * - `url` - Url to redirect to. * - `queryParam` - Query parameter name for the target url. * - `statusCode` - Redirection status code. + * - `allowedRedirectExtensions` - If true, redirects are allowed for all extensions. + * Pass specific ones to allow list, or false to disallow redirects for any extension. * * @var array */ @@ -44,6 +46,7 @@ class RedirectHandler implements HandlerInterface 'url' => '/login', 'queryParam' => 'redirect', 'statusCode' => 302, + 'allowedRedirectExtensions' => true, ]; /** @@ -58,7 +61,7 @@ public function handle( ): ResponseInterface { $options += $this->defaultOptions; - if (!$this->checkException($exception, $options['exceptions'])) { + if (!$this->redirectAllowed($request, $options) || !$this->checkException($exception, $options['exceptions'])) { throw $exception; } @@ -106,7 +109,7 @@ protected function getUrl(ServerRequestInterface $request, array $options): stri $redirect .= '?' . $uri->getQuery(); } $query = urlencode($options['queryParam']) . '=' . urlencode($redirect); - if (strpos($url, '?') !== false) { + if (str_contains($url, '?')) { $query = '&' . $query; } else { $query = '?' . $query; @@ -117,4 +120,28 @@ protected function getUrl(ServerRequestInterface $request, array $options): stri return $url; } + + /** + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param array $options + * @return bool + */ + protected function redirectAllowed(ServerRequestInterface $request, array $options): bool + { + $extensions = $options['allowedRedirectExtensions'] ?? true; + if ($extensions === false) { + return false; + } + if ($extensions === true) { + return true; + } + + /** @var \Cake\Http\ServerRequest $request */ + $currentExtension = $request->getParam('_ext'); + if (!$currentExtension) { + return true; + } + + return in_array($currentExtension, (array)$extensions, true); + } } diff --git a/tests/TestCase/Middleware/UnauthorizedHandler/RedirectHandlerTest.php b/tests/TestCase/Middleware/UnauthorizedHandler/RedirectHandlerTest.php index bbbaa81..9eda131 100644 --- a/tests/TestCase/Middleware/UnauthorizedHandler/RedirectHandlerTest.php +++ b/tests/TestCase/Middleware/UnauthorizedHandler/RedirectHandlerTest.php @@ -17,10 +17,12 @@ namespace Authorization\Test\TestCase\Middleware\UnauthorizedHandler; use Authorization\Exception\Exception; +use Authorization\Exception\MissingIdentityException; use Authorization\Middleware\UnauthorizedHandler\RedirectHandler; use Cake\Core\Configure; use Cake\Http\ServerRequestFactory; use Cake\TestSuite\TestCase; +use LogicException; use PHPUnit\Framework\Attributes\DataProvider; class RedirectHandlerTest extends TestCase @@ -160,4 +162,91 @@ public function testHandleException() $this->expectException(Exception::class); $handler->handle($exception, $request); } + + public function testHandleRedirectionWithExtensionsFalse(): void + { + $handler = new RedirectHandler(); + + $exception = new MissingIdentityException(); + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'GET'], + ); + $request = $request->withParam('_ext', 'csv'); + + $this->expectException(MissingIdentityException::class); + + $handler->handle($exception, $request, [ + 'exceptions' => [ + LogicException::class, + ], + 'url' => '/users/login', + 'allowedRedirectExtensions' => false, + ]); + } + + public function testHandleRedirectionWithExtension(): void + { + $handler = new RedirectHandler(); + + $exception = new MissingIdentityException(); + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'GET'], + ); + $request = $request->withParam('_ext', 'csv'); + + $this->expectException(MissingIdentityException::class); + + $handler->handle($exception, $request, [ + 'exceptions' => [ + MissingIdentityException::class, + ], + 'url' => '/users/login', + 'allowedRedirectExtensions' => [], + ]); + } + + public function testHandleRedirectionWithExtensionAllowlisted(): void + { + $handler = new RedirectHandler(); + + $exception = new MissingIdentityException(); + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'GET'], + ); + $request = $request->withParam('_ext', 'csv'); + + $response = $handler->handle($exception, $request, [ + 'exceptions' => [ + Exception::class, + ], + 'url' => '/users/login', + 'queryParam' => null, + 'allowedRedirectExtensions' => ['csv'], + ]); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('/users/login', $response->getHeaderLine('Location')); + } + + public function testHandleRedirectionWithExtensionAllowedNoExtensionInRequest(): void + { + $handler = new RedirectHandler(); + + $exception = new Exception(); + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'GET'], + ); + + $response = $handler->handle($exception, $request, [ + 'exceptions' => [ + Exception::class, + ], + 'url' => '/users/login', + 'queryParam' => null, + 'allowedRedirectExtensions' => ['csv'], + ]); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('/users/login', $response->getHeaderLine('Location')); + } }