Skip to content

Commit cec0009

Browse files
arthur791004claude
andauthored
PCG: follow redirects on probe loopback to fix "Too many redirects" (#48897)
* PCG: follow redirects on probe loopback to fix "Too many redirects" The probe was firing with redirects=0, so any 3xx response from the loopback (force_ssl_admin's http->https bounce on the admin probe, canonical http->https / trailing-slash on the front-end probe) threw "Too many redirects" — about 11k of these in logstash. Switching to redirects=3 lets Requests follow the bounce, re-sending the forwarded Cookie header on the followed request, so admin auth still validates and the probe handler still fires at the canonical front-end URL (WP emits full-URL Location headers so pcg_probe/token survives). parse_response now classifies: - Too-many-redirects exception (budget exceeded) -> ok-inconclusive, matched on WpOrg\Requests\Exception::getType() === 'toomanyredirects' so a future upstream wording change can't reclassify these. - 3xx safety net (Requests refused to follow — cross-scheme downgrade, malformed Location, etc.) -> ok-inconclusive, broadened from the admin-only 301/302 check that's no longer reachable for the common cases. - 2xx with redirect_count > 0 -> ok-inconclusive (destination dropped our pcg_probe query string), distinguished from the no-redirects 2xx which still classifies as fatal (real exit/die during bootstrap). Drop the now-unused $is_admin param on parse_response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: bump redirect budget from 3 to 5 to match wp_remote_get default Absorbs multilingual / 4-hop canonical chains (http -> https -> www -> trailing-slash -> locale-prefix) without changing the failure mode at the cap (still ok-inconclusive, non-blocking). Aligns with WP core's own loopback convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: tighten 'redirects' rationale comment Drop a few lines while keeping the load-bearing facts: budget aligns with wp_remote_get's default, what kinds of hops it absorbs, and why the probe query + auth survive the bounce. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: update changelog blurb to reflect 5-redirect budget Stale "up to 3" from the initial commit; bumped to 5 to match wp_remote_get's default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: defer probe transient delete until verdict emits If force_ssl_admin redirects http->https before admin_init runs, the loopback follows with the same token but the transient was already deleted on the first hit, so the follow-up bailed with "Invalid or expired probe token". Stash the key and delete only inside the terminal responder; the 30s TTL caps any lingering entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: strip forwarded Cookie on cross-host probe redirect Now that the loopback follows up to 5 redirects, a misconfigured admin URL that bounces off-host would leak the forwarded auth cookies to the third party. Register a per-request requests.before_redirect hook that drops the Cookie header whenever the redirect target's host differs from the original admin host. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: trim probe comments and simplify redirect cookie strip Drop diff-narrating comments around the redirect/2xx/3xx branches in parse_response and the redirect budget block. Collapse the case-insensitive Cookie-header scan in the cross-host redirect hook to a direct unset (Requests passes through the caller's exact-case key). Rename the closure's $return param to $return_value to satisfy PHPCS, and realign equals signs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: also strip Cookie on same-host https→http redirect Address review: relative redirects inherit the original origin/scheme, and https→http on the same host now drops the forwarded Cookie too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PCG: case-insensitive Cookie strip in redirect hook Future-proof against a Requests change that normalizes header keys before invoking before_redirect: scan req_headers case-insensitively so the cross-host / scheme-downgrade Cookie strip can't silently no-op if the key arrives as 'cookie' or 'COOKIE'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2a188b6 commit cec0009

4 files changed

Lines changed: 110 additions & 16 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: changed
3+
4+
Plugin Conflicts Guardian: follow up to 5 redirects on the probe loopback so canonical http→https and force_ssl_admin scheme bounces stop returning "Too many redirects".

projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Two independent filters:
2626
2. `activation-guard.php` intercepts on `load-plugins.php` / `load-update.php` priority 0, verifies the nonce, filters the request down to eligible plugins (passes `validate_file`, not already active, file exists), and calls `PCG_Load_Tester::test()` once with the full batch.
2727
3. The load tester stashes `{ plugins, mode }` in a short-lived transient keyed by a random token, then fires the probe against `?pcg_probe=1&token=…` on this same site. Activation flows pass `mode = activation`; the post-update health check passes `mode = update` (see "Post-update health check" below).
2828
4. `probe-endpoint.php` runs synchronously at require time (already inside `plugins_loaded` priority 10 via `load_features()`), validates + consumes the token, gates on the per-mode filter (`pcg_guard_activation` for activation, `pcg_guard_updates` for update), defines `WP_SANDBOX_SCRAPING` so core's fatal handler steps aside, arms a shutdown handler, and in activation mode `require_once`s each plugin's main file in order under that single request. Probe cost is constant regardless of how many plugins are activated, and conflicts that only fire when two plugins load together (duplicate class, shared global) are caught — which a per-plugin probe model couldn't see. In update mode the files are already loaded by WP's normal bootstrap and re-requiring would fatal with "Cannot redeclare class/function" — the probe just verifies that bootstrap completed cleanly.
29-
5. Two probes fire in parallel via `\WpOrg\Requests\Requests::request_multiple()`: one against `home_url('/')` (front-end) and one against `admin_url('index.php')` with `pcg_admin=1` and the admin's WP auth cookies forwarded so `auth_redirect()` clears. The admin probe defers its verdict to `admin_init` priority `PHP_INT_MAX`; the front-end probe emits on `wp_loaded`. A captured `fatal` / `throwable` from either probe wins; otherwise the front-end verdict is returned. A 302 on the admin probe (cookies missing/expired) becomes a distinct `ok-inconclusive` status that's still treated as a non-blocking pass — that way transport quirks don't break activation, but the signal can be measured separately from a clean `ok`.
29+
5. Two probes fire in parallel via `\WpOrg\Requests\Requests::request_multiple()`: one against `home_url('/')` (front-end) and one against `admin_url('index.php')` with `pcg_admin=1` and the admin's WP auth cookies forwarded so `auth_redirect()` clears. Requests follows up to 5 redirects (matching `wp_remote_get`'s default), so canonical http→https / trailing-slash / non-www→www on the front-end and `force_ssl_admin`'s http→https bounce on the admin probe both reach a real verdict (WP emits full-URL `Location:` headers from `home_url`/`set_url_scheme` so `pcg_probe`/`token` survives, and Requests re-sends the forwarded `Cookie:` header on the followed request so admin auth still validates after the scheme bounce). The admin probe defers its verdict to `admin_init` priority `PHP_INT_MAX`; the front-end probe emits on `wp_loaded`. A captured `fatal` / `throwable` from either probe wins; otherwise the front-end verdict is returned. Inconclusive verdicts (exceeded redirect budget, destination dropped the probe query, or unfollowed 3xx) become `ok-inconclusive` — a non-blocking pass since the bootstrap was healthy enough to redirect or render — so they're distinguishable from a clean `ok` in logs without breaking activation.
3030
6. On a fatal/throwable the guard attributes the failure to one plugin in the batch — preferring the explicit `plugin` field (set when a `Throwable` is caught around the `require`), then falling back to matching the captured `file` against each plugin's directory. The whole batch is blocked as a unit; the notice tells the admin which plugin caused the fatal so they can retry without it.
3131

3232
```

projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/class-pcg-load-tester.php

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
6060
),
6161
array(
6262
'timeout' => self::PROBE_TIMEOUT,
63-
'redirects' => 0,
63+
// Match `wp_remote_get`'s default; covers http->https,
64+
// force_ssl_admin's scheme bounce, and locale redirects.
65+
// `build_same_host_cookie_hook` keeps admin auth from
66+
// leaking if the redirect points off-host.
67+
'redirects' => 5,
6468
)
6569
);
6670
} catch ( \Throwable $t ) {
@@ -73,8 +77,8 @@ public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
7377
delete_transient( self::transient_key( $admin['token'] ) );
7478
}
7579

76-
$front_result = $this->parse_response( $responses['front'], false );
77-
$admin_result = $this->parse_response( $responses['admin'], true );
80+
$front_result = $this->parse_response( $responses['front'] );
81+
$admin_result = $this->parse_response( $responses['admin'] );
7882

7983
// Log transport-level errors (most often timeouts at PROBE_TIMEOUT)
8084
// so we can see how often they fire before deciding whether to
@@ -209,11 +213,13 @@ protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mo
209213
'token' => $token,
210214
);
211215
$headers = array();
216+
$options = array();
212217
if ( $is_admin ) {
213218
$query['pcg_admin'] = '1';
214219
$cookie_header = $this->collect_auth_cookie_header();
215220
if ( '' !== $cookie_header ) {
216221
$headers['Cookie'] = $cookie_header;
222+
$options['hooks'] = $this->build_same_host_cookie_hook( $base_url );
217223
}
218224
}
219225

@@ -223,6 +229,7 @@ protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mo
223229
'url' => add_query_arg( $query, $base_url ),
224230
'type' => 'GET',
225231
'headers' => $headers,
232+
'options' => $options,
226233
),
227234
);
228235
}
@@ -232,31 +239,41 @@ protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mo
232239
*
233240
* @param mixed $response A `WpOrg\Requests\Response`, or an exception
234241
* thrown for that single request.
235-
* @param bool $is_admin True when this was the admin probe.
236242
* @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
237243
*/
238-
protected function parse_response( $response, $is_admin ) {
244+
protected function parse_response( $response ) {
239245
if ( $response instanceof \Throwable ) {
246+
// Bootstrap was healthy enough to issue several redirects in
247+
// a row, so treat redirect-budget exhaustion as inconclusive
248+
// rather than an error.
249+
if ( $response instanceof \WpOrg\Requests\Exception && 'toomanyredirects' === $response->getType() ) {
250+
return array(
251+
'status' => 'ok-inconclusive',
252+
'reason' => 'Probe exceeded redirect budget; treating as inconclusive ok.',
253+
);
254+
}
240255
return array(
241256
'status' => 'error',
242257
'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ),
243258
);
244259
}
245260

246-
$code = (int) ( $response->status_code ?? 0 );
247-
$body = (string) ( $response->body ?? '' );
261+
$code = (int) ( $response->status_code ?? 0 );
262+
$body = (string) ( $response->body ?? '' );
263+
$redirect_count = (int) ( $response->redirects ?? 0 );
248264

249265
$decoded = json_decode( $body, true );
250266
if ( is_array( $decoded ) && isset( $decoded['status'] ) ) {
251267
return $decoded;
252268
}
253269

254-
// Admin probe bounced to login (no/expired cookie). Distinct status so
255-
// we can measure how often it fires; treated as ok by callers.
256-
if ( $is_admin && ( 301 === $code || 302 === $code ) ) {
270+
// 3xx that Requests refused to follow (cross-scheme downgrade,
271+
// malformed Location). Treat as ok — bootstrap completed enough
272+
// to emit one.
273+
if ( $code >= 300 && $code < 400 ) {
257274
return array(
258275
'status' => 'ok-inconclusive',
259-
'reason' => 'Admin probe redirected; treating as inconclusive ok.',
276+
'reason' => sprintf( 'Probe redirected (HTTP %d); treating as inconclusive ok.', $code ),
260277
);
261278
}
262279

@@ -267,10 +284,18 @@ protected function parse_response( $response, $is_admin ) {
267284
);
268285
}
269286

270-
// Probe endpoint always emits JSON; a 2xx without one means the
271-
// bootstrap was terminated mid-flight (exit/die during load/init/admin_init).
272-
// Block, since the same termination would affect matching future requests.
273287
if ( $code >= 200 && $code < 300 ) {
288+
// Followed a redirect whose destination dropped the probe
289+
// query. Bootstrap rendered cleanly, so don't block.
290+
if ( $redirect_count > 0 ) {
291+
return array(
292+
'status' => 'ok-inconclusive',
293+
'reason' => sprintf( 'Probe followed %d redirect(s) but destination dropped the probe query; treating as inconclusive ok.', $redirect_count ),
294+
);
295+
}
296+
// Probe endpoint always emits JSON; a 2xx without one and no
297+
// redirect means the bootstrap was terminated mid-flight
298+
// (exit/die during load/init/admin_init).
274299
return array(
275300
'status' => 'fatal',
276301
'message' => sprintf(
@@ -309,6 +334,47 @@ protected function collect_auth_cookie_header() {
309334
return implode( '; ', $pairs );
310335
}
311336

337+
/**
338+
* Build a Hooks instance that strips the forwarded `Cookie:` header on
339+
* any redirect that leaves the original origin: off-host, or an
340+
* https→http scheme downgrade on the same host. Defends against
341+
* leaking admin auth cookies (we forward `Cookie:` manually, so
342+
* Requests won't enforce browser `Secure` semantics for us).
343+
* Relative redirects inherit the original origin and pass through.
344+
*
345+
* @param string $original_url Initial probe URL whose origin is the trust boundary.
346+
* @return \WpOrg\Requests\Hooks
347+
*/
348+
protected function build_same_host_cookie_hook( $original_url ) {
349+
$original = wp_parse_url( $original_url );
350+
$original_host = isset( $original['host'] ) ? strtolower( (string) $original['host'] ) : '';
351+
$original_scheme = isset( $original['scheme'] ) ? strtolower( (string) $original['scheme'] ) : '';
352+
353+
$hooks = new \WpOrg\Requests\Hooks();
354+
$hooks->register(
355+
'requests.before_redirect',
356+
static function ( &$location, &$req_headers, &$req_data, &$options, $return_value ) use ( $original_host, $original_scheme ) {
357+
unset( $req_data, $options, $return_value );
358+
if ( ! is_array( $req_headers ) ) {
359+
return;
360+
}
361+
$next = wp_parse_url( (string) $location );
362+
$next_host = isset( $next['host'] ) ? strtolower( (string) $next['host'] ) : $original_host;
363+
$next_scheme = isset( $next['scheme'] ) ? strtolower( (string) $next['scheme'] ) : $original_scheme;
364+
$same_host = '' !== $next_host && $next_host === $original_host;
365+
$scheme_downgrade = 'https' === $original_scheme && 'https' !== $next_scheme;
366+
if ( ! $same_host || $scheme_downgrade ) {
367+
foreach ( array_keys( $req_headers ) as $key ) {
368+
if ( 0 === strcasecmp( (string) $key, 'Cookie' ) ) {
369+
unset( $req_headers[ $key ] );
370+
}
371+
}
372+
}
373+
}
374+
);
375+
return $hooks;
376+
}
377+
312378
/**
313379
* Transient key for a probe token. Shared with the endpoint.
314380
*

projects/packages/jetpack-mu-wpcom/src/features/plugin-conflicts-guardian/probe-endpoint.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@ function pcg_maybe_handle_probe() {
3030

3131
$key = PCG_Load_Tester::transient_key( $token );
3232
$payload = get_transient( $key );
33-
delete_transient( $key );
3433

3534
if ( ! is_array( $payload ) || ! isset( $payload['plugins'] ) || ! isset( $payload['mode'] ) ) {
3635
pcg_probe_bail_error( 'Invalid or expired probe token.', 403 );
3736
}
37+
38+
// Defer deletion until we actually emit a verdict. If something redirects
39+
// (e.g. force_ssl_admin's http->https bounce) before admin_init, Requests
40+
// follows with the same token; deleting upfront would make the follow-up
41+
// fail with "Invalid or expired probe token". The 30s transient TTL caps
42+
// lingering entries when no terminal response runs.
43+
pcg_probe_pending_key( $key );
3844
$plugin_mains = is_array( $payload['plugins'] ) ? array_values(
3945
array_filter(
4046
array_map( static fn( $p ) => (string) $p, $payload['plugins'] ),
@@ -145,13 +151,31 @@ function pcg_probe_shutdown() {
145151
* @return never
146152
*/
147153
function pcg_probe_respond( $payload, $status = 200 ) {
154+
$key = pcg_probe_pending_key();
155+
if ( '' !== $key ) {
156+
delete_transient( $key );
157+
}
148158
while ( ob_get_level() > 0 ) {
149159
ob_end_clean();
150160
}
151161
wp_send_json( $payload, (int) $status, JSON_UNESCAPED_SLASHES );
152162
exit;
153163
}
154164

165+
/**
166+
* Get/set the transient key to delete when we emit a verdict.
167+
*
168+
* @param string|null $set Key to remember; omit to read the current value.
169+
* @return string
170+
*/
171+
function pcg_probe_pending_key( $set = null ) {
172+
static $key = '';
173+
if ( null !== $set ) {
174+
$key = (string) $set;
175+
}
176+
return $key;
177+
}
178+
155179
/**
156180
* Emit an `error` verdict with the given reason + HTTP status, and terminate.
157181
*

0 commit comments

Comments
 (0)