| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Decides whether a computed redirect destination can actually terminate. |
| 9 |
* |
| 10 |
* Every redirect the plugin emits passes through |
| 11 |
* {@see ABJ_404_Solution_NotFoundResponseService::forceRedirect()}, which |
| 12 |
* resolves a target and then appends the current request's comment-page and |
| 13 |
* query-string parts to it. Both of those steps can produce a destination that |
| 14 |
* sends the visitor straight back to the request being answered. This class is |
| 15 |
* the decision layer in front of the emission: hand it the destination and the |
| 16 |
* resolved target, and it answers with a destination that terminates, or false |
| 17 |
* meaning "emit no redirect at all". |
| 18 |
* |
| 19 |
* Two independent loops are recognized, because they are visible in different |
| 20 |
* places: |
| 21 |
* - The cookie loop (avoidInfiniteRedirect): request A redirects to B and B |
| 22 |
* redirects back to A. Only visible across requests, via the previous |
| 23 |
* request cookie. |
| 24 |
* - The self loop (avoidSelfRedirect): the destination IS the request. Visible |
| 25 |
* within the single request being answered, with no cookie involved. |
| 26 |
* |
| 27 |
* Business logic only: no data access, no response writing. |
| 28 |
* |
| 29 |
* // allow-no-test-found: exercised by RedirectSelfLoopGuardTest |
| 30 |
*/ |
| 31 |
class ABJ_404_Solution_RedirectLoopGuard { |
| 32 |
|
| 33 |
/** @var ABJ_404_Solution_Functions */ |
| 34 |
private $functions; |
| 35 |
|
| 36 |
/** @var ABJ_404_Solution_Logging */ |
| 37 |
private $logger; |
| 38 |
|
| 39 |
/** @var ABJ_404_Solution_PreviousRequestCookieTracker */ |
| 40 |
private $previousRequestCookieTracker; |
| 41 |
|
| 42 |
/** |
| 43 |
* @param ABJ_404_Solution_Functions|null $functions |
| 44 |
* @param ABJ_404_Solution_Logging|null $logging |
| 45 |
* @param ABJ_404_Solution_PreviousRequestCookieTracker|null $previousRequestCookieTracker |
| 46 |
*/ |
| 47 |
function __construct($functions = null, $logging = null, $previousRequestCookieTracker = null) { |
| 48 |
$this->functions = $functions !== null ? $functions : abj_service('functions'); |
| 49 |
$this->logger = $logging !== null ? $logging : abj_service('logging'); |
| 50 |
$this->previousRequestCookieTracker = $previousRequestCookieTracker !== null |
| 51 |
? $previousRequestCookieTracker |
| 52 |
: abj_service('previous_request_cookie_tracker'); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Reduce a destination to one that terminates. |
| 57 |
* |
| 58 |
* @param array{finalDestination: string, location: string} $request |
| 59 |
* @return string|false a destination that does not loop, or false when no |
| 60 |
* redirect can terminate and none should be sent. |
| 61 |
*/ |
| 62 |
function terminatingDestination(array $request) { |
| 63 |
$finalDestination = $request['finalDestination']; |
| 64 |
$location = $request['location']; |
| 65 |
$loopSafeDestination = $this->avoidInfiniteRedirect(array( |
| 66 |
'finalDestination' => $finalDestination, |
| 67 |
'location' => $location, |
| 68 |
)); |
| 69 |
if ($loopSafeDestination === false) { |
| 70 |
return false; |
| 71 |
} |
| 72 |
|
| 73 |
return $this->avoidSelfRedirect(array( |
| 74 |
'finalDestination' => $loopSafeDestination, |
| 75 |
'location' => $location, |
| 76 |
)); |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* @param array{finalDestination: string, location: string} $request |
| 81 |
* @return string|false |
| 82 |
*/ |
| 83 |
private function avoidInfiniteRedirect(array $request) { |
| 84 |
$finalDestination = $request['finalDestination']; |
| 85 |
$location = $request['location']; |
| 86 |
$previousRequest = is_object($this->previousRequestCookieTracker) |
| 87 |
? $this->previousRequestCookieTracker->readCookieWithPreviousRqeuestShort() |
| 88 |
: ''; |
| 89 |
if (empty($previousRequest)) { |
| 90 |
return $finalDestination; |
| 91 |
} |
| 92 |
|
| 93 |
$finalDestNoHome = $this->redirectPathOnly($finalDestination); |
| 94 |
$locationNoHome = $this->redirectPathOnly($location); |
| 95 |
if ($previousRequest == $finalDestNoHome && $previousRequest != $locationNoHome) { |
| 96 |
$this->logger->infoMessage("Maybe avoided infite redirects to/from: " . $previousRequest); |
| 97 |
return $location; |
| 98 |
} |
| 99 |
|
| 100 |
if ($previousRequest == $finalDestination) { |
| 101 |
$this->logger->infoMessage("Avoided infite redirects to/from: " . $previousRequest); |
| 102 |
return false; |
| 103 |
} |
| 104 |
|
| 105 |
return $finalDestination; |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Never answer a request with a redirect back to that same request. |
| 110 |
* |
| 111 |
* The destination handed to forceRedirect() is the resolved target with |
| 112 |
* the current request's comment-page and query-string parts appended |
| 113 |
* (buildFinalRedirectDestination). When the target's own path is already |
| 114 |
* the requested path, appending the request's query reconstructs the |
| 115 |
* requested URL exactly and the 301 sends the visitor straight back. That |
| 116 |
* is what a homepage 404 destination does to a request like `/?page=1`: |
| 117 |
* `get_home_url()` has no trailing slash, so the destination becomes |
| 118 |
* "https://site.com" . "?page=1", which is the request. Every trip round |
| 119 |
* the loop is a fresh WordPress 404, so the captured-404 row's hit count |
| 120 |
* climbs by one per iteration until the browser gives up. |
| 121 |
* |
| 122 |
* avoidInfiniteRedirect() cannot see this: its cookie deliberately stores |
| 123 |
* the request with the query string stripped, so a loop that exists only |
| 124 |
* because of the query string is invisible to it. This check compares the |
| 125 |
* outgoing Location against the request being answered directly, with no |
| 126 |
* cookie involved, so it also holds on the very first request, for |
| 127 |
* cookie-less clients (crawlers, curl), and behind caches that drop |
| 128 |
* Set-Cookie. |
| 129 |
* |
| 130 |
* Scheme is deliberately not part of the comparison. A destination that |
| 131 |
* differs from the request only by scheme still 404s on arrival and loops |
| 132 |
* one hop later; dropping the appended query fixes the loop and the scheme |
| 133 |
* in a single redirect. |
| 134 |
* |
| 135 |
* @param array{finalDestination: string, location: string} $request the |
| 136 |
* destination including appended comment-page/query parts and the |
| 137 |
* resolved target before those parts were appended. |
| 138 |
* @return string|false a destination that is not the current request, or |
| 139 |
* false when no redirect can terminate. |
| 140 |
*/ |
| 141 |
private function avoidSelfRedirect(array $request) { |
| 142 |
$finalDestination = $request['finalDestination']; |
| 143 |
$location = $request['location']; |
| 144 |
if ($finalDestination === '') { |
| 145 |
return $finalDestination; |
| 146 |
} |
| 147 |
|
| 148 |
$requestAuthority = $this->normalizeAuthority( |
| 149 |
isset($_SERVER['HTTP_HOST']) && is_string($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '' |
| 150 |
); |
| 151 |
$requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) |
| 152 |
? $_SERVER['REQUEST_URI'] : ''; |
| 153 |
if ($requestUri === '') { |
| 154 |
// No request context (CLI, WP-Cron): nothing to compare against. |
| 155 |
return $finalDestination; |
| 156 |
} |
| 157 |
|
| 158 |
$requestKey = $this->urlIdentityKey(array( |
| 159 |
'url' => $requestUri, |
| 160 |
'requestAuthority' => $requestAuthority, |
| 161 |
)); |
| 162 |
$destinationIsRequest = ($this->urlIdentityKey(array( |
| 163 |
'url' => $finalDestination, |
| 164 |
'requestAuthority' => $requestAuthority, |
| 165 |
)) === $requestKey); |
| 166 |
$targetIsRequest = ($location !== '' && $this->urlIdentityKey(array( |
| 167 |
'url' => $location, |
| 168 |
'requestAuthority' => $requestAuthority, |
| 169 |
)) === $requestKey); |
| 170 |
|
| 171 |
if ($targetIsRequest || ($destinationIsRequest && $location === '')) { |
| 172 |
// The configured target IS the requested URL. Dropping the appended |
| 173 |
// parts cannot help, so emit no redirect and let WordPress answer |
| 174 |
// the request it already resolved. |
| 175 |
$this->logger->warn('Skipped a redirect whose destination is the URL being requested: ' . |
| 176 |
$finalDestination); |
| 177 |
return false; |
| 178 |
} |
| 179 |
|
| 180 |
if ($destinationIsRequest) { |
| 181 |
// The appended query/comment part is what turned the target back |
| 182 |
// into the request. Honor the configured destination without it. |
| 183 |
$this->logger->infoMessage('Dropped the request query string from a redirect destination that ' . |
| 184 |
'would otherwise have pointed back at the request. Destination: ' . $location); |
| 185 |
return $location; |
| 186 |
} |
| 187 |
|
| 188 |
return $finalDestination; |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Reduce a URL to the parts that decide whether two URLs are the same |
| 193 |
* resource: authority (host plus non-default port), path, and the query as |
| 194 |
* an order-independent set. |
| 195 |
* |
| 196 |
* Empty paths normalize to "/" ("https://site.com" and "https://site.com/" |
| 197 |
* are one URL). Trailing slashes are otherwise left alone, because |
| 198 |
* "/foo" and "/foo/" are different URLs and redirecting between them |
| 199 |
* terminates. A URL with no host of its own is read as host-relative and |
| 200 |
* takes the request's authority. |
| 201 |
* |
| 202 |
* The port has to be in the key and has to be normalized on both sides: |
| 203 |
* $_SERVER['HTTP_HOST'] carries it inline ("localhost:8888") while |
| 204 |
* parse_url() splits it into its own component, so comparing bare hosts |
| 205 |
* silently never matches on any site not served from port 80/443. |
| 206 |
* |
| 207 |
* @param array{url: string, requestAuthority: string} $request |
| 208 |
* @return string comparison key; never treat it as a URL. |
| 209 |
*/ |
| 210 |
private function urlIdentityKey(array $request): string { |
| 211 |
$url = $request['url']; |
| 212 |
$requestAuthority = $request['requestAuthority']; |
| 213 |
$parts = parse_url($url); |
| 214 |
if (!is_array($parts)) { |
| 215 |
// Unparseable URLs are only ever equal to themselves. |
| 216 |
return "\0unparseable\0" . $url; |
| 217 |
} |
| 218 |
|
| 219 |
$authority = $requestAuthority; |
| 220 |
if (isset($parts['host']) && is_string($parts['host']) && $parts['host'] !== '') { |
| 221 |
$port = isset($parts['port']) ? (int)$parts['port'] : 0; |
| 222 |
$authority = $this->normalizeAuthority( |
| 223 |
$parts['host'] . ($port > 0 ? ':' . $port : '') |
| 224 |
); |
| 225 |
} |
| 226 |
|
| 227 |
$path = isset($parts['path']) && is_string($parts['path']) && $parts['path'] !== '' |
| 228 |
? $parts['path'] : '/'; |
| 229 |
$query = isset($parts['query']) && is_string($parts['query']) ? $parts['query'] : ''; |
| 230 |
|
| 231 |
$pairs = ($query === '') ? array() : explode('&', $query); |
| 232 |
sort($pairs); |
| 233 |
|
| 234 |
return $authority . '|' . $path . '|' . implode('&', $pairs); |
| 235 |
} |
| 236 |
|
| 237 |
/** |
| 238 |
* Lowercase a host[:port] pair and drop the ports browsers leave implicit, |
| 239 |
* so "Site.com:443" and "site.com" compare equal. |
| 240 |
* |
| 241 |
* @param string $authority |
| 242 |
* @return string |
| 243 |
*/ |
| 244 |
private function normalizeAuthority(string $authority): string { |
| 245 |
$normalized = strtolower(trim($authority)); |
| 246 |
if ($normalized === '') { |
| 247 |
return ''; |
| 248 |
} |
| 249 |
$colonPos = strrpos($normalized, ':'); |
| 250 |
if ($colonPos === false) { |
| 251 |
return $normalized; |
| 252 |
} |
| 253 |
$port = $this->functions->substr($normalized, $colonPos + 1); |
| 254 |
if ($port === '80' || $port === '443') { |
| 255 |
return $this->functions->substr($normalized, 0, $colonPos); |
| 256 |
} |
| 257 |
return $normalized; |
| 258 |
} |
| 259 |
|
| 260 |
private function redirectPathOnly(string $url): string { |
| 261 |
$schemePos = $this->functions->strpos($url, '://'); |
| 262 |
$withoutHost = ($schemePos !== false) |
| 263 |
? $this->functions->substr($url, $schemePos + 3) : $url; |
| 264 |
$slashPos = $this->functions->strpos($withoutHost, '/'); |
| 265 |
return ($slashPos !== false) ? $this->functions->substr($withoutHost, $slashPos) : '/'; |
| 266 |
} |
| 267 |
|
| 268 |
} |
| 269 |
|