PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / redirects / RedirectLoopGuard.php

RedirectLoopGuard.php in 404 Solution trunk, at includes/redirects/RedirectLoopGuard.php

269 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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