Skip to content

Commit 3e302f6

Browse files
authored
Add optional redirect loop protection to AuthenticationService (#752)
* Add optional redirect loop protection to AuthenticationService - Add configurable redirect validation to prevent redirect loop attacks - Checks for nested redirects, deep encoding, blocked patterns, and URL length - Disabled by default for backward compatibility (opt-in) - Add comprehensive test coverage (8 new tests) - Add detailed documentation with security considerations - Fixes issue #751 Real-world evidence shows bots creating 6-7 levels of nested redirects, wasting server resources and potentially enabling security exploits. Configuration example: 'redirectValidation' => [ 'enabled' => true, 'maxDepth' => 1, 'maxEncodingLevels' => 1, 'maxLength' => 2000, 'blockedPatterns' => ['#/login#i', '#/logout#i'], ] * Address PR review feedback - Fix comparison operators: Use >= instead of > for maxDepth and maxEncodingLevels This correctly blocks URLs when they meet or exceed the threshold - Replace empty() with ! for enabled check (cleaner intent) - All tests still pass (312 tests, 926 assertions) Addresses feedback from @ADmad and @Copilot in PR #752 * Simplify implementation by removing blockedPatterns Address maintainer feedback from @markstory and @ADmad: - Remove blockedPatterns configuration option - Remove pattern-based validation logic - Update documentation to show custom pattern validation in subclass - Remove 2 pattern-based tests (testGetLoginRedirectValidationBlockedPatterns, testGetLoginRedirectValidationCustomPatterns) Result: Simpler, focused implementation covering the core security issues: - Nested redirect detection - Deep encoding detection - URL length limits Custom pattern blocking can still be achieved by overriding validateRedirect(). All tests pass: 310 tests, 920 assertions Code style checks pass
1 parent 846f9b9 commit 3e302f6

File tree

4 files changed

+409
-1
lines changed

4 files changed

+409
-1
lines changed

docs/en/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ Further Reading
256256
* :doc:`/authentication-component`
257257
* :doc:`/impersonation`
258258
* :doc:`/url-checkers`
259+
* :doc:`/redirect-validation`
259260
* :doc:`/testing`
260261
* :doc:`/view-helper`
261262
* :doc:`/migration-from-the-authcomponent`

docs/en/redirect-validation.rst

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
Redirect Validation
2+
###################
3+
4+
The Authentication plugin provides optional redirect validation to prevent redirect loop attacks
5+
and malicious redirect patterns that could be exploited by bots or attackers.
6+
7+
.. _security-redirect-loops:
8+
9+
Preventing Redirect Loops
10+
==========================
11+
12+
By default, the authentication service does not validate redirect URLs beyond checking that they
13+
are relative (not external). This means that malicious actors or misconfigured bots could create
14+
deeply nested redirect chains like:
15+
16+
.. code-block:: text
17+
18+
/login?redirect=/login?redirect=/login?redirect=/protected/page
19+
20+
These nested redirects can waste server resources, pollute logs, and potentially enable security
21+
exploits.
22+
23+
Enabling Redirect Validation
24+
=============================
25+
26+
To enable redirect validation, configure the ``redirectValidation`` option in your
27+
``AuthenticationService``:
28+
29+
.. code-block:: php
30+
31+
// In src/Application.php getAuthenticationService() method
32+
$service = new AuthenticationService();
33+
$service->setConfig([
34+
'unauthenticatedRedirect' => '/users/login',
35+
'queryParam' => 'redirect',
36+
'redirectValidation' => [
37+
'enabled' => true, // Enable validation (default: false)
38+
],
39+
]);
40+
41+
Configuration Options
42+
=====================
43+
44+
The ``redirectValidation`` configuration accepts the following options:
45+
46+
enabled
47+
**Type:** ``bool`` | **Default:** ``false``
48+
49+
Whether to enable redirect validation. Disabled by default for backward compatibility.
50+
51+
maxDepth
52+
**Type:** ``int`` | **Default:** ``1``
53+
54+
Maximum number of nested redirect parameters allowed. For example, with ``maxDepth`` set to 1,
55+
``/login?redirect=/articles`` is valid, but ``/login?redirect=/login?redirect=/articles`` is blocked.
56+
57+
maxEncodingLevels
58+
**Type:** ``int`` | **Default:** ``1``
59+
60+
Maximum URL encoding levels allowed. This prevents obfuscation attacks using double or triple
61+
encoding (e.g., ``%252F`` for double-encoded ``/``).
62+
63+
maxLength
64+
**Type:** ``int`` | **Default:** ``2000``
65+
66+
Maximum allowed length of the redirect URL in characters. This helps prevent DOS attacks
67+
via excessively long URLs.
68+
69+
Example Configuration
70+
=====================
71+
72+
Here's a complete example with custom configuration:
73+
74+
.. code-block:: php
75+
76+
$service = new AuthenticationService();
77+
$service->setConfig([
78+
'unauthenticatedRedirect' => '/users/login',
79+
'queryParam' => 'redirect',
80+
'redirectValidation' => [
81+
'enabled' => true,
82+
'maxDepth' => 1,
83+
'maxEncodingLevels' => 1,
84+
'maxLength' => 2000,
85+
],
86+
]);
87+
88+
How Validation Works
89+
====================
90+
91+
When redirect validation is enabled and a redirect URL fails validation, ``getLoginRedirect()``
92+
will return ``null`` instead of the invalid URL. Your application should handle this by
93+
redirecting to a default location:
94+
95+
.. code-block:: php
96+
97+
// In your controller
98+
$target = $this->Authentication->getLoginRedirect() ?? '/';
99+
return $this->redirect($target);
100+
101+
Validation Checks
102+
=================
103+
104+
The validation performs the following checks in order:
105+
106+
1. **Redirect Depth**: Counts occurrences of ``redirect=`` in the decoded URL
107+
2. **Encoding Level**: Counts occurrences of ``%25`` (percent-encoded percent sign)
108+
3. **URL Length**: Checks total character count
109+
110+
If any check fails, the URL is rejected.
111+
112+
Custom Validation
113+
=================
114+
115+
You can extend ``AuthenticationService`` and override the ``validateRedirect()`` method
116+
to implement custom validation logic, such as blocking specific URL patterns:
117+
118+
.. code-block:: php
119+
120+
namespace App\Auth;
121+
122+
use Authentication\AuthenticationService;
123+
124+
class CustomAuthenticationService extends AuthenticationService
125+
{
126+
protected function validateRedirect(string $redirect): ?string
127+
{
128+
// Call parent validation first
129+
$redirect = parent::validateRedirect($redirect);
130+
131+
if ($redirect === null) {
132+
return null;
133+
}
134+
135+
// Add your custom validation
136+
// Example: Block redirects to authentication pages
137+
if (preg_match('#/(login|logout|register)#i', $redirect)) {
138+
return null;
139+
}
140+
141+
// Example: Block redirects to admin areas
142+
if (str_contains($redirect, '/admin')) {
143+
return null;
144+
}
145+
146+
return $redirect;
147+
}
148+
}
149+
150+
Backward Compatibility
151+
======================
152+
153+
Redirect validation is **disabled by default** to maintain backward compatibility with existing
154+
applications. To enable it, explicitly set ``'enabled' => true`` in the configuration.
155+
156+
Security Considerations
157+
=======================
158+
159+
While redirect validation helps prevent common attacks, it should be part of a comprehensive
160+
security strategy that includes:
161+
162+
* Rate limiting to prevent bot abuse
163+
* Monitoring and logging of blocked redirects
164+
* Regular security audits
165+
* Keeping the Authentication plugin up to date
166+
167+
Real-World Attack Example
168+
=========================
169+
170+
In production environments, bots (especially AI crawlers like GPTBot) have been observed
171+
creating redirect chains with 6-7 levels of nesting:
172+
173+
.. code-block:: text
174+
175+
/login?redirect=%2Flogin%3Fredirect%3D%252Flogin%253Fredirect%253D...
176+
177+
Enabling redirect validation prevents these attacks and protects your application from:
178+
179+
* Resource exhaustion (CPU wasted parsing deeply nested URLs)
180+
* Log pollution (malformed URLs flooding access logs)
181+
* SEO damage (search engines indexing login pages with loops)
182+
* Potential security exploits when combined with other vulnerabilities
183+
184+
For more information on redirect attacks, see:
185+
186+
* `OWASP: Unvalidated Redirects and Forwards <https://owasp.org/www-community/attacks/Unvalidated_Redirects_and_Forwards>`_
187+
* `CWE-601: URL Redirection to Untrusted Site <https://cwe.mitre.org/data/definitions/601.html>`_

src/AuthenticationService.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,18 @@ class AuthenticationService implements AuthenticationServiceInterface, Impersona
8383
* AuthenticationComponent::allowUnauthenticated()
8484
* - `queryParam` - The name of the query string parameter containing the previously blocked URL
8585
* in case of unauthenticated redirect, or null to disable appending the denied URL.
86+
* - `redirectValidation` - Configuration for validating redirect URLs to prevent loops. See below.
87+
*
88+
* ### Redirect Validation Configuration:
89+
*
90+
* ```
91+
* 'redirectValidation' => [
92+
* 'enabled' => true, // Enable validation (default: false for BC)
93+
* 'maxDepth' => 1, // Max nested "redirect=" parameters (default: 1)
94+
* 'maxEncodingLevels' => 1, // Max percent-encoding levels (default: 1)
95+
* 'maxLength' => 2000, // Max URL length in characters (default: 2000)
96+
* ]
97+
* ```
8698
*
8799
* ### Example:
88100
*
@@ -105,6 +117,12 @@ class AuthenticationService implements AuthenticationServiceInterface, Impersona
105117
'identityAttribute' => 'identity',
106118
'queryParam' => null,
107119
'unauthenticatedRedirect' => null,
120+
'redirectValidation' => [
121+
'enabled' => false, // Disabled by default for backward compatibility
122+
'maxDepth' => 1,
123+
'maxEncodingLevels' => 1,
124+
'maxLength' => 2000,
125+
],
108126
];
109127

110128
/**
@@ -457,7 +475,49 @@ public function getLoginRedirect(ServerRequestInterface $request): ?string
457475
$parsed['query'] = "?{$parsed['query']}";
458476
}
459477

460-
return $parsed['path'] . $parsed['query'];
478+
$redirect = $parsed['path'] . $parsed['query'];
479+
480+
// Validate redirect to prevent loops if enabled
481+
return $this->validateRedirect($redirect);
482+
}
483+
484+
/**
485+
* Validates a redirect URL to prevent loops and malicious patterns
486+
*
487+
* This method can be overridden in subclasses to implement custom validation logic.
488+
*
489+
* @param string $redirect The redirect URL to validate
490+
* @return string|null The validated URL or null if invalid
491+
*/
492+
protected function validateRedirect(string $redirect): ?string
493+
{
494+
$config = $this->getConfig('redirectValidation');
495+
496+
// If validation is disabled, return the URL as-is (backward compatibility)
497+
if (!$config['enabled']) {
498+
return $redirect;
499+
}
500+
501+
$decodedUrl = urldecode($redirect);
502+
503+
// Check for nested redirect parameters
504+
$redirectCount = substr_count($decodedUrl, 'redirect=');
505+
if ($redirectCount >= $config['maxDepth']) {
506+
return null;
507+
}
508+
509+
// Check for multiple encoding levels (e.g., %25 = percent-encoded %)
510+
$encodingCount = substr_count($redirect, '%25');
511+
if ($encodingCount >= $config['maxEncodingLevels']) {
512+
return null;
513+
}
514+
515+
// Check URL length to prevent DOS attacks
516+
if (strlen($redirect) > $config['maxLength']) {
517+
return null;
518+
}
519+
520+
return $redirect;
461521
}
462522

463523
/**

0 commit comments

Comments
 (0)