Skip to content

Commit 56130d3

Browse files
committed
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'], ]
1 parent 846f9b9 commit 56130d3

4 files changed

Lines changed: 516 additions & 1 deletion

File tree

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: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
blockedPatterns
70+
**Type:** ``array`` | **Default:** ``['#/login#i', '#/logout#i', '#/register#i']``
71+
72+
Array of regular expressions to match against redirect URLs. Matching URLs will be rejected.
73+
This prevents redirects to authentication-related pages that could cause loops.
74+
75+
Example Configuration
76+
=====================
77+
78+
Here's a complete example with custom configuration:
79+
80+
.. code-block:: php
81+
82+
$service = new AuthenticationService();
83+
$service->setConfig([
84+
'unauthenticatedRedirect' => '/users/login',
85+
'queryParam' => 'redirect',
86+
'redirectValidation' => [
87+
'enabled' => true,
88+
'maxDepth' => 1,
89+
'maxEncodingLevels' => 1,
90+
'maxLength' => 2000,
91+
'blockedPatterns' => [
92+
'#/admin#i', // Block admin areas
93+
'#/login#i', // Block login page
94+
'#/logout#i', // Block logout page
95+
'#/register#i', // Block registration page
96+
],
97+
],
98+
]);
99+
100+
How Validation Works
101+
====================
102+
103+
When redirect validation is enabled and a redirect URL fails validation, ``getLoginRedirect()``
104+
will return ``null`` instead of the invalid URL. Your application should handle this by
105+
redirecting to a default location:
106+
107+
.. code-block:: php
108+
109+
// In your controller
110+
$target = $this->Authentication->getLoginRedirect() ?? '/';
111+
return $this->redirect($target);
112+
113+
Validation Checks
114+
=================
115+
116+
The validation performs the following checks in order:
117+
118+
1. **Redirect Depth**: Counts occurrences of ``redirect=`` in the decoded URL
119+
2. **Encoding Level**: Counts occurrences of ``%25`` (percent-encoded percent sign)
120+
3. **URL Length**: Checks total character count
121+
4. **Blocked Patterns**: Matches against configured regex patterns
122+
123+
If any check fails, the URL is rejected.
124+
125+
Custom Validation
126+
=================
127+
128+
You can extend ``AuthenticationService`` and override the ``validateRedirect()`` method
129+
to implement custom validation logic:
130+
131+
.. code-block:: php
132+
133+
namespace App\Auth;
134+
135+
use Authentication\AuthenticationService;
136+
137+
class CustomAuthenticationService extends AuthenticationService
138+
{
139+
protected function validateRedirect(string $redirect): ?string
140+
{
141+
// Call parent validation first
142+
$redirect = parent::validateRedirect($redirect);
143+
144+
if ($redirect === null) {
145+
return null;
146+
}
147+
148+
// Add your custom validation
149+
if (str_contains($redirect, 'forbidden')) {
150+
return null; // Reject this URL
151+
}
152+
153+
return $redirect;
154+
}
155+
}
156+
157+
Backward Compatibility
158+
======================
159+
160+
Redirect validation is **disabled by default** to maintain backward compatibility with existing
161+
applications. To enable it, explicitly set ``'enabled' => true`` in the configuration.
162+
163+
Security Considerations
164+
=======================
165+
166+
While redirect validation helps prevent common attacks, it should be part of a comprehensive
167+
security strategy that includes:
168+
169+
* Rate limiting to prevent bot abuse
170+
* Monitoring and logging of blocked redirects
171+
* Regular security audits
172+
* Keeping the Authentication plugin up to date
173+
174+
Real-World Attack Example
175+
=========================
176+
177+
In production environments, bots (especially AI crawlers like GPTBot) have been observed
178+
creating redirect chains with 6-7 levels of nesting:
179+
180+
.. code-block:: text
181+
182+
/login?redirect=%2Flogin%3Fredirect%3D%252Flogin%253Fredirect%253D...
183+
184+
Enabling redirect validation prevents these attacks and protects your application from:
185+
186+
* Resource exhaustion (CPU wasted parsing deeply nested URLs)
187+
* Log pollution (malformed URLs flooding access logs)
188+
* SEO damage (search engines indexing login pages with loops)
189+
* Potential security exploits when combined with other vulnerabilities
190+
191+
For more information on redirect attacks, see:
192+
193+
* `OWASP: Unvalidated Redirects and Forwards <https://owasp.org/www-community/attacks/Unvalidated_Redirects_and_Forwards>`_
194+
* `CWE-601: URL Redirection to Untrusted Site <https://cwe.mitre.org/data/definitions/601.html>`_

src/AuthenticationService.php

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ 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+
* 'blockedPatterns' => [ // Regex patterns to reject (default: auth pages)
97+
* '#/login#i',
98+
* '#/logout#i',
99+
* ],
100+
* ]
101+
* ```
86102
*
87103
* ### Example:
88104
*
@@ -105,6 +121,17 @@ class AuthenticationService implements AuthenticationServiceInterface, Impersona
105121
'identityAttribute' => 'identity',
106122
'queryParam' => null,
107123
'unauthenticatedRedirect' => null,
124+
'redirectValidation' => [
125+
'enabled' => false, // Disabled by default for backward compatibility
126+
'maxDepth' => 1,
127+
'maxEncodingLevels' => 1,
128+
'maxLength' => 2000,
129+
'blockedPatterns' => [
130+
'#/login#i',
131+
'#/logout#i',
132+
'#/register#i',
133+
],
134+
],
108135
];
109136

110137
/**
@@ -457,7 +484,56 @@ public function getLoginRedirect(ServerRequestInterface $request): ?string
457484
$parsed['query'] = "?{$parsed['query']}";
458485
}
459486

460-
return $parsed['path'] . $parsed['query'];
487+
$redirect = $parsed['path'] . $parsed['query'];
488+
489+
// Validate redirect to prevent loops if enabled
490+
return $this->validateRedirect($redirect);
491+
}
492+
493+
/**
494+
* Validates a redirect URL to prevent loops and malicious patterns
495+
*
496+
* This method can be overridden in subclasses to implement custom validation logic.
497+
*
498+
* @param string $redirect The redirect URL to validate
499+
* @return string|null The validated URL or null if invalid
500+
*/
501+
protected function validateRedirect(string $redirect): ?string
502+
{
503+
$config = $this->getConfig('redirectValidation');
504+
505+
// If validation is disabled, return the URL as-is (backward compatibility)
506+
if (empty($config['enabled'])) {
507+
return $redirect;
508+
}
509+
510+
$decodedUrl = urldecode($redirect);
511+
512+
// Check for nested redirect parameters
513+
$redirectCount = substr_count($decodedUrl, 'redirect=');
514+
if ($redirectCount > $config['maxDepth']) {
515+
return null;
516+
}
517+
518+
// Check for multiple encoding levels (e.g., %25 = percent-encoded %)
519+
$encodingCount = substr_count($redirect, '%25');
520+
if ($encodingCount > $config['maxEncodingLevels']) {
521+
return null;
522+
}
523+
524+
// Check URL length to prevent DOS attacks
525+
if (strlen($redirect) > $config['maxLength']) {
526+
return null;
527+
}
528+
529+
// Check against blocked patterns
530+
foreach ($config['blockedPatterns'] as $pattern) {
531+
if (preg_match($pattern, $decodedUrl)) {
532+
return null;
533+
}
534+
}
535+
536+
return $redirect;
461537
}
462538

463539
/**

0 commit comments

Comments
 (0)