diff --git a/docs/en/index.rst b/docs/en/index.rst index e39834c8..4f12ea25 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -256,6 +256,7 @@ Further Reading * :doc:`/authentication-component` * :doc:`/impersonation` * :doc:`/url-checkers` +* :doc:`/redirect-validation` * :doc:`/testing` * :doc:`/view-helper` * :doc:`/migration-from-the-authcomponent` diff --git a/docs/en/redirect-validation.rst b/docs/en/redirect-validation.rst new file mode 100644 index 00000000..a08301c8 --- /dev/null +++ b/docs/en/redirect-validation.rst @@ -0,0 +1,187 @@ +Redirect Validation +################### + +The Authentication plugin provides optional redirect validation to prevent redirect loop attacks +and malicious redirect patterns that could be exploited by bots or attackers. + +.. _security-redirect-loops: + +Preventing Redirect Loops +========================== + +By default, the authentication service does not validate redirect URLs beyond checking that they +are relative (not external). This means that malicious actors or misconfigured bots could create +deeply nested redirect chains like: + +.. code-block:: text + + /login?redirect=/login?redirect=/login?redirect=/protected/page + +These nested redirects can waste server resources, pollute logs, and potentially enable security +exploits. + +Enabling Redirect Validation +============================= + +To enable redirect validation, configure the ``redirectValidation`` option in your +``AuthenticationService``: + +.. code-block:: php + + // In src/Application.php getAuthenticationService() method + $service = new AuthenticationService(); + $service->setConfig([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, // Enable validation (default: false) + ], + ]); + +Configuration Options +===================== + +The ``redirectValidation`` configuration accepts the following options: + +enabled + **Type:** ``bool`` | **Default:** ``false`` + + Whether to enable redirect validation. Disabled by default for backward compatibility. + +maxDepth + **Type:** ``int`` | **Default:** ``1`` + + Maximum number of nested redirect parameters allowed. For example, with ``maxDepth`` set to 1, + ``/login?redirect=/articles`` is valid, but ``/login?redirect=/login?redirect=/articles`` is blocked. + +maxEncodingLevels + **Type:** ``int`` | **Default:** ``1`` + + Maximum URL encoding levels allowed. This prevents obfuscation attacks using double or triple + encoding (e.g., ``%252F`` for double-encoded ``/``). + +maxLength + **Type:** ``int`` | **Default:** ``2000`` + + Maximum allowed length of the redirect URL in characters. This helps prevent DOS attacks + via excessively long URLs. + +Example Configuration +===================== + +Here's a complete example with custom configuration: + +.. code-block:: php + + $service = new AuthenticationService(); + $service->setConfig([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, + 'maxDepth' => 1, + 'maxEncodingLevels' => 1, + 'maxLength' => 2000, + ], + ]); + +How Validation Works +==================== + +When redirect validation is enabled and a redirect URL fails validation, ``getLoginRedirect()`` +will return ``null`` instead of the invalid URL. Your application should handle this by +redirecting to a default location: + +.. code-block:: php + + // In your controller + $target = $this->Authentication->getLoginRedirect() ?? '/'; + return $this->redirect($target); + +Validation Checks +================= + +The validation performs the following checks in order: + +1. **Redirect Depth**: Counts occurrences of ``redirect=`` in the decoded URL +2. **Encoding Level**: Counts occurrences of ``%25`` (percent-encoded percent sign) +3. **URL Length**: Checks total character count + +If any check fails, the URL is rejected. + +Custom Validation +================= + +You can extend ``AuthenticationService`` and override the ``validateRedirect()`` method +to implement custom validation logic, such as blocking specific URL patterns: + +.. code-block:: php + + namespace App\Auth; + + use Authentication\AuthenticationService; + + class CustomAuthenticationService extends AuthenticationService + { + protected function validateRedirect(string $redirect): ?string + { + // Call parent validation first + $redirect = parent::validateRedirect($redirect); + + if ($redirect === null) { + return null; + } + + // Add your custom validation + // Example: Block redirects to authentication pages + if (preg_match('#/(login|logout|register)#i', $redirect)) { + return null; + } + + // Example: Block redirects to admin areas + if (str_contains($redirect, '/admin')) { + return null; + } + + return $redirect; + } + } + +Backward Compatibility +====================== + +Redirect validation is **disabled by default** to maintain backward compatibility with existing +applications. To enable it, explicitly set ``'enabled' => true`` in the configuration. + +Security Considerations +======================= + +While redirect validation helps prevent common attacks, it should be part of a comprehensive +security strategy that includes: + +* Rate limiting to prevent bot abuse +* Monitoring and logging of blocked redirects +* Regular security audits +* Keeping the Authentication plugin up to date + +Real-World Attack Example +========================= + +In production environments, bots (especially AI crawlers like GPTBot) have been observed +creating redirect chains with 6-7 levels of nesting: + +.. code-block:: text + + /login?redirect=%2Flogin%3Fredirect%3D%252Flogin%253Fredirect%253D... + +Enabling redirect validation prevents these attacks and protects your application from: + +* Resource exhaustion (CPU wasted parsing deeply nested URLs) +* Log pollution (malformed URLs flooding access logs) +* SEO damage (search engines indexing login pages with loops) +* Potential security exploits when combined with other vulnerabilities + +For more information on redirect attacks, see: + +* `OWASP: Unvalidated Redirects and Forwards `_ +* `CWE-601: URL Redirection to Untrusted Site `_ diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 71c498c7..c9d03513 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -83,6 +83,18 @@ class AuthenticationService implements AuthenticationServiceInterface, Impersona * AuthenticationComponent::allowUnauthenticated() * - `queryParam` - The name of the query string parameter containing the previously blocked URL * in case of unauthenticated redirect, or null to disable appending the denied URL. + * - `redirectValidation` - Configuration for validating redirect URLs to prevent loops. See below. + * + * ### Redirect Validation Configuration: + * + * ``` + * 'redirectValidation' => [ + * 'enabled' => true, // Enable validation (default: false for BC) + * 'maxDepth' => 1, // Max nested "redirect=" parameters (default: 1) + * 'maxEncodingLevels' => 1, // Max percent-encoding levels (default: 1) + * 'maxLength' => 2000, // Max URL length in characters (default: 2000) + * ] + * ``` * * ### Example: * @@ -105,6 +117,12 @@ class AuthenticationService implements AuthenticationServiceInterface, Impersona 'identityAttribute' => 'identity', 'queryParam' => null, 'unauthenticatedRedirect' => null, + 'redirectValidation' => [ + 'enabled' => false, // Disabled by default for backward compatibility + 'maxDepth' => 1, + 'maxEncodingLevels' => 1, + 'maxLength' => 2000, + ], ]; /** @@ -457,7 +475,49 @@ public function getLoginRedirect(ServerRequestInterface $request): ?string $parsed['query'] = "?{$parsed['query']}"; } - return $parsed['path'] . $parsed['query']; + $redirect = $parsed['path'] . $parsed['query']; + + // Validate redirect to prevent loops if enabled + return $this->validateRedirect($redirect); + } + + /** + * Validates a redirect URL to prevent loops and malicious patterns + * + * This method can be overridden in subclasses to implement custom validation logic. + * + * @param string $redirect The redirect URL to validate + * @return string|null The validated URL or null if invalid + */ + protected function validateRedirect(string $redirect): ?string + { + $config = $this->getConfig('redirectValidation'); + + // If validation is disabled, return the URL as-is (backward compatibility) + if (!$config['enabled']) { + return $redirect; + } + + $decodedUrl = urldecode($redirect); + + // Check for nested redirect parameters + $redirectCount = substr_count($decodedUrl, 'redirect='); + if ($redirectCount >= $config['maxDepth']) { + return null; + } + + // Check for multiple encoding levels (e.g., %25 = percent-encoded %) + $encodingCount = substr_count($redirect, '%25'); + if ($encodingCount >= $config['maxEncodingLevels']) { + return null; + } + + // Check URL length to prevent DOS attacks + if (strlen($redirect) > $config['maxLength']) { + return null; + } + + return $redirect; } /** diff --git a/tests/TestCase/AuthenticationServiceTest.php b/tests/TestCase/AuthenticationServiceTest.php index 39849630..d2e13d50 100644 --- a/tests/TestCase/AuthenticationServiceTest.php +++ b/tests/TestCase/AuthenticationServiceTest.php @@ -947,6 +947,166 @@ public function testGetLoginRedirect() ); } + /** + * testGetLoginRedirectValidationDisabled + * + * @return void + */ + public function testGetLoginRedirectValidationDisabled() + { + $service = new AuthenticationService([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => false, + ], + ]); + + // With validation disabled, even nested redirects should pass + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/login?redirect=/secrets'], + ); + $this->assertSame( + '/login?redirect=/secrets', + $service->getLoginRedirect($request), + ); + } + + /** + * testGetLoginRedirectValidationNestedRedirects + * + * @return void + */ + public function testGetLoginRedirectValidationNestedRedirects() + { + $service = new AuthenticationService([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, + ], + ]); + + // Valid single-level redirect + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/articles/view/1'], + ); + $this->assertSame( + '/articles/view/1', + $service->getLoginRedirect($request), + ); + + // Nested redirect (should be blocked) + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/login?redirect=/articles/view/1'], + ); + $this->assertNull($service->getLoginRedirect($request)); + + // Deeply nested redirect (should be blocked) + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/login?redirect=%2Flogin%3Fredirect%3D%252Farticles'], + ); + $this->assertNull($service->getLoginRedirect($request)); + } + + /** + * testGetLoginRedirectValidationEncodingLevels + * + * @return void + */ + public function testGetLoginRedirectValidationEncodingLevels() + { + $service = new AuthenticationService([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, + ], + ]); + + // Normal single-encoded URL (should pass) + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/articles%2Fview%2F1'], + ); + $this->assertSame( + '/articles%2Fview%2F1', + $service->getLoginRedirect($request), + ); + + // Double-encoded URL (should be blocked) + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/articles%252Fview%252F1'], + ); + $this->assertNull($service->getLoginRedirect($request)); + } + + /** + * testGetLoginRedirectValidationMaxLength + * + * @return void + */ + public function testGetLoginRedirectValidationMaxLength() + { + $service = new AuthenticationService([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, + 'maxLength' => 100, + ], + ]); + + // Short URL (should pass) + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/articles/view/1'], + ); + $this->assertSame( + '/articles/view/1', + $service->getLoginRedirect($request), + ); + + // Excessively long URL (should be blocked) + $longUrl = '/articles/' . str_repeat('a', 150); + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => $longUrl], + ); + $this->assertNull($service->getLoginRedirect($request)); + } + + /** + * testGetLoginRedirectValidationWithQueryParameters + * + * @return void + */ + public function testGetLoginRedirectValidationWithQueryParameters() + { + $service = new AuthenticationService([ + 'unauthenticatedRedirect' => '/users/login', + 'queryParam' => 'redirect', + 'redirectValidation' => [ + 'enabled' => true, + ], + ]); + + // Valid redirect with query parameters + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_URI' => '/secrets'], + ['redirect' => '/articles/index?sort=created&direction=desc'], + ); + $this->assertSame( + '/articles/index?sort=created&direction=desc', + $service->getLoginRedirect($request), + ); + } + /** * testImpersonate *