Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
42.50% |
102 / 240 |
|
27.78% |
5 / 18 |
CRAP | |
0.00% |
0 / 1 |
| PCG_Load_Tester | |
42.50% |
102 / 240 |
|
27.78% |
5 / 18 |
1458.54 | |
0.00% |
0 / 1 |
| test | |
20.75% |
11 / 53 |
|
0.00% |
0 / 1 |
126.97 | |||
| confirm_via_normal_load | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
| is_clean_confirmation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| downgrade_after_confirmation | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| is_block | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| is_error | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| is_anomalous_allow | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| log_probe_anomaly | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| probe_anomaly_label | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| relative_basenames | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| build_probe_payload | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| prepare_probe | |
80.00% |
20 / 25 |
|
0.00% |
0 / 1 |
4.13 | |||
| parse_response | |
56.25% |
27 / 48 |
|
0.00% |
0 / 1 |
27.15 | |||
| probe_endpoint_was_reached | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| collect_auth_cookie_header | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
| build_same_host_cookie_hook | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
156 | |||
| classify_shutdown | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| transient_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * HTTP-based plugin-load probe. |
| 4 | * |
| 5 | * @package automattic/jetpack-mu-wpcom |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * Runs a plugin's main file in a separate HTTP self-request and |
| 10 | * returns the probe verdict as an associative array. |
| 11 | */ |
| 12 | class PCG_Load_Tester { |
| 13 | |
| 14 | const PROBE_TIMEOUT = 15; |
| 15 | |
| 16 | /** |
| 17 | * Probe-token transient TTL, in seconds. |
| 18 | * |
| 19 | * Must outlast the *whole* probe, not a single hop: each followed |
| 20 | * redirect (`redirects => 5`) is a fresh request that re-reads the same |
| 21 | * transient, so the worst case is `PROBE_TIMEOUT` × several hops. The |
| 22 | * old 30s was shorter than that and a slow redirect chain (e.g. the |
| 23 | * force_ssl_admin http→https bounce on a sluggish site) could outlive |
| 24 | * the token, making the endpoint bail with "Invalid or expired probe |
| 25 | * token." `test()` deletes the transient in its `finally` block anyway, |
| 26 | * so a generous TTL never leaks. |
| 27 | */ |
| 28 | const TOKEN_LIFETIME = 300; |
| 29 | |
| 30 | /** |
| 31 | * Engine-fatal mask used by the probe shutdown classifier. Anything |
| 32 | * outside this mask (notice, warning, deprecation, or `error_get_last` |
| 33 | * returning null after a clean `exit`) is treated as not-a-fatal. |
| 34 | * |
| 35 | * `E_RECOVERABLE_ERROR` is included even though it's "catchable" by a |
| 36 | * custom `set_error_handler` — by the time PHP shutdown fires with |
| 37 | * `error_get_last()` still pointing at it, no handler caught it, so |
| 38 | * it terminated execution like any other fatal. The probe context |
| 39 | * doesn't install a recovery handler, so the only way this errno |
| 40 | * lands in `error_get_last()` here is the uncaught path. |
| 41 | */ |
| 42 | const SHUTDOWN_FATAL_MASK = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR; |
| 43 | |
| 44 | /** Activation guard: plugins are inactive; endpoint require_once's each. */ |
| 45 | const MODE_ACTIVATION = 'activation'; |
| 46 | |
| 47 | /** |
| 48 | * Post-update healthcheck: plugins are already loaded by WP's bootstrap; |
| 49 | * endpoint skips require_once (would fatal with "Cannot redeclare"). |
| 50 | */ |
| 51 | const MODE_UPDATE = 'update'; |
| 52 | |
| 53 | /** |
| 54 | * Probe a batch of plugin main files in one loopback request pair. |
| 55 | * |
| 56 | * Fires front-end + admin probes in parallel; front-end auth cookies are |
| 57 | * forwarded so admin_init can fire. Fatal from either wins; otherwise |
| 58 | * front-end's verdict. On fatal/throwable, the verdict's `plugin` key |
| 59 | * names the file the endpoint was loading at the time. |
| 60 | * |
| 61 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 62 | * @param string $mode self::MODE_ACTIVATION or self::MODE_UPDATE. |
| 63 | * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string} |
| 64 | */ |
| 65 | public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) { |
| 66 | $plugin_mains = array_values( |
| 67 | array_filter( |
| 68 | array_map( static fn( $p ) => (string) $p, $plugin_mains ), |
| 69 | static fn( $p ) => '' !== $p && is_file( $p ) && is_readable( $p ) |
| 70 | ) |
| 71 | ); |
| 72 | if ( empty( $plugin_mains ) ) { |
| 73 | return array( |
| 74 | 'status' => 'error', |
| 75 | 'reason' => 'No probable plugin main files supplied.', |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | $front = $this->prepare_probe( $plugin_mains, home_url( '/' ), false, $mode ); |
| 80 | $admin = $this->prepare_probe( $plugin_mains, admin_url( 'index.php' ), true, $mode ); |
| 81 | |
| 82 | try { |
| 83 | $responses = \WpOrg\Requests\Requests::request_multiple( |
| 84 | array( |
| 85 | 'front' => $front['request'], |
| 86 | 'admin' => $admin['request'], |
| 87 | ), |
| 88 | array( |
| 89 | 'timeout' => self::PROBE_TIMEOUT, |
| 90 | // Match `wp_remote_get`'s default; covers http->https, |
| 91 | // force_ssl_admin's scheme bounce, and locale redirects. |
| 92 | // `build_same_host_cookie_hook` keeps admin auth from |
| 93 | // leaking if the redirect points off-host. |
| 94 | 'redirects' => 5, |
| 95 | ) |
| 96 | ); |
| 97 | } catch ( \Throwable $t ) { |
| 98 | return array( |
| 99 | 'status' => 'error', |
| 100 | 'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ), |
| 101 | ); |
| 102 | } finally { |
| 103 | delete_transient( self::transient_key( $front['token'] ) ); |
| 104 | delete_transient( self::transient_key( $admin['token'] ) ); |
| 105 | } |
| 106 | |
| 107 | $front_result = $this->parse_response( $responses['front'] ); |
| 108 | $admin_result = $this->parse_response( $responses['admin'] ); |
| 109 | |
| 110 | // Every surface that captured a fatal. An admin-only fatal still |
| 111 | // crashes the site, so both must be confirmed. Front-end is the |
| 112 | // canonical verdict when both block. |
| 113 | $blocking = array(); |
| 114 | if ( $this->is_block( $front_result ) ) { |
| 115 | $blocking['front'] = $front_result; |
| 116 | } |
| 117 | if ( $this->is_block( $admin_result ) ) { |
| 118 | $blocking['admin'] = $admin_result; |
| 119 | } |
| 120 | |
| 121 | if ( ! empty( $blocking ) ) { |
| 122 | $verdict = $this->is_block( $front_result ) ? $front_result : $admin_result; |
| 123 | |
| 124 | // Confirm each blocking surface via WP's normal active-plugin |
| 125 | // bootstrap; downgrade only when EVERY one comes back an explicit |
| 126 | // clean `ok`. Anything else keeps the captured fatal (see |
| 127 | // is_clean_confirmation). Update mode never confirms — fatals must |
| 128 | // block so PCG_Rollback can fire. |
| 129 | if ( self::MODE_ACTIVATION === $mode ) { |
| 130 | $clean_confirmation = null; |
| 131 | foreach ( array_keys( $blocking ) as $surface ) { |
| 132 | $confirmation = $this->confirm_via_normal_load( $plugin_mains, $surface ); |
| 133 | if ( ! $this->is_clean_confirmation( $confirmation ) ) { |
| 134 | $clean_confirmation = null; |
| 135 | break; |
| 136 | } |
| 137 | $clean_confirmation = $confirmation; |
| 138 | } |
| 139 | if ( null !== $clean_confirmation ) { |
| 140 | return $this->downgrade_after_confirmation( $verdict, $clean_confirmation ); |
| 141 | } |
| 142 | } |
| 143 | return $verdict; |
| 144 | } |
| 145 | |
| 146 | // Neither probe blocked. Log if either verdict was a transport-level |
| 147 | // error or a synthesized ok-inconclusive (HTTP 500, marker+non-JSON, |
| 148 | // redirect-budget exhaustion) — those are the allow paths under the |
| 149 | // "only block on captured fatal" policy, and the log is how we |
| 150 | // measure how often we let an activation/update through despite a |
| 151 | // suspicious signal. |
| 152 | if ( $this->is_anomalous_allow( $front_result ) || $this->is_anomalous_allow( $admin_result ) ) { |
| 153 | $this->log_probe_anomaly( $mode, $plugin_mains, $front_result, $admin_result ); |
| 154 | } |
| 155 | |
| 156 | return $front_result; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Fire a confirmation probe for a single surface with `pcg_confirm=1`. |
| 161 | * Returns null on transport failure — the caller keeps the original |
| 162 | * verdict on uncertainty. |
| 163 | * |
| 164 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 165 | * @param string $surface Which surface to re-probe: `front` or `admin`. |
| 166 | * @return array|null Parsed verdict, or null on transport failure. |
| 167 | */ |
| 168 | protected function confirm_via_normal_load( array $plugin_mains, $surface = 'front' ) { |
| 169 | $is_admin = 'admin' === $surface; |
| 170 | $base_url = $is_admin ? admin_url( 'index.php' ) : home_url( '/' ); |
| 171 | $probe = $this->prepare_probe( $plugin_mains, $base_url, $is_admin, self::MODE_ACTIVATION, true ); |
| 172 | |
| 173 | try { |
| 174 | $responses = \WpOrg\Requests\Requests::request_multiple( |
| 175 | array( $surface => $probe['request'] ), |
| 176 | array( |
| 177 | 'timeout' => self::PROBE_TIMEOUT, |
| 178 | 'redirects' => 5, |
| 179 | ) |
| 180 | ); |
| 181 | } catch ( \Throwable $t ) { |
| 182 | return null; |
| 183 | } finally { |
| 184 | delete_transient( self::transient_key( $probe['token'] ) ); |
| 185 | } |
| 186 | |
| 187 | return $this->parse_response( $responses[ $surface ] ); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Whether a confirmation-probe verdict is an explicit clean load. Only |
| 192 | * status=`ok` qualifies — the probe endpoint emits it from |
| 193 | * wp_loaded/admin_init once the candidate loaded via the real |
| 194 | * active_plugins path and the whole bootstrap completed without a |
| 195 | * captured fatal. This is the sole result that downgrades a captured |
| 196 | * fatal: a fatal during the active_plugins-driven load dies in |
| 197 | * wp-settings.php before the endpoint registers its shutdown handler, |
| 198 | * so it can never return as status=`fatal` — it comes back as a 500 / |
| 199 | * `ok-inconclusive` / transport error (null), none of which we trust |
| 200 | * to override a genuine captured fatal. |
| 201 | * |
| 202 | * @param array|null $confirmation Confirmation verdict, or null on transport failure. |
| 203 | * @return bool |
| 204 | */ |
| 205 | protected function is_clean_confirmation( $confirmation ) { |
| 206 | return is_array( $confirmation ) && 'ok' === (string) ( $confirmation['status'] ?? '' ); |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Downgraded verdict after a clean confirmation. Preserves the |
| 211 | * original captured-fatal context for log attribution. |
| 212 | * |
| 213 | * @param array $verdict Original captured-fatal verdict. |
| 214 | * @param array $confirmation Clean confirmation-probe verdict. |
| 215 | * @return array |
| 216 | */ |
| 217 | protected function downgrade_after_confirmation( array $verdict, array $confirmation ) { |
| 218 | pcg_log_event( |
| 219 | 'Probe fatal downgraded after confirmation', |
| 220 | array( |
| 221 | 'plugin' => isset( $verdict['plugin'] ) ? $this->relative_basenames( array( (string) $verdict['plugin'] ) )[0] : '', |
| 222 | 'status' => (string) ( $verdict['status'] ?? '' ), |
| 223 | 'reason' => (string) ( $verdict['message'] ?? $verdict['reason'] ?? '' ), |
| 224 | 'file' => isset( $verdict['file'] ) ? basename( (string) $verdict['file'] ) : '', |
| 225 | 'confirm' => (string) ( $confirmation['status'] ?? '' ), |
| 226 | ) |
| 227 | ); |
| 228 | $out = array( |
| 229 | 'status' => 'ok-inconclusive', |
| 230 | 'reason' => 'Captured fatal did not reproduce when the candidate was loaded via WP\'s normal active-plugin bootstrap; downgrading to allow.', |
| 231 | 'message' => (string) ( $verdict['message'] ?? '' ), |
| 232 | 'file' => (string) ( $verdict['file'] ?? '' ), |
| 233 | ); |
| 234 | if ( '' !== (string) ( $verdict['plugin'] ?? '' ) ) { |
| 235 | $out['plugin'] = (string) $verdict['plugin']; |
| 236 | } |
| 237 | return $out; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Whether a verdict is a captured fatal that should block the activation. |
| 242 | * |
| 243 | * @param array $result Probe verdict. |
| 244 | * @return bool |
| 245 | */ |
| 246 | protected function is_block( $result ) { |
| 247 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 248 | return 'fatal' === $status || 'throwable' === $status; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Whether a verdict is a transport-level error (timeout, connection |
| 253 | * failure, non-JSON body). Distinct from `is_block` — errors are |
| 254 | * inconclusive and don't block activation, but are worth logging. |
| 255 | * |
| 256 | * @param array $result Probe verdict. |
| 257 | * @return bool |
| 258 | */ |
| 259 | protected function is_error( $result ) { |
| 260 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 261 | return 'error' === $status; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Whether a verdict is one we chose to allow despite a suspicious |
| 266 | * signal — either a transport `error` or a synthesized |
| 267 | * `ok-inconclusive` from `parse_response` (HTTP 500, marker+non-JSON, |
| 268 | * redirect-budget exhaustion, dropped probe query on redirect). We log |
| 269 | * these so the dashboard can show the rate at which we're letting |
| 270 | * activations through without a captured verdict — the cost of the |
| 271 | * "only block on captured fatal" policy. |
| 272 | * |
| 273 | * @param array $result Probe verdict. |
| 274 | * @return bool |
| 275 | */ |
| 276 | protected function is_anomalous_allow( $result ) { |
| 277 | if ( $this->is_error( $result ) ) { |
| 278 | return true; |
| 279 | } |
| 280 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 281 | // `ok-shutdown` means the probe reached PHP shutdown without a |
| 282 | // wp_loaded/admin_init verdict — bootstrap died silently or a |
| 283 | // plugin called `exit` during init. Not a captured fatal, so we |
| 284 | // allow under the policy, but the rate is worth logging because |
| 285 | // it's the new replacement for the old "marker present, no |
| 286 | // JSON body" class. |
| 287 | return 'ok-inconclusive' === $status || 'ok-shutdown' === $status; |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Log a probe anomaly (transport error or synthesized |
| 292 | * ok-inconclusive) to logstash whenever either probe came back as |
| 293 | * such. Lets us measure how often we silently allow despite a |
| 294 | * suspicious signal — the observability backstop for the |
| 295 | * "only block on captured fatal" policy. |
| 296 | * |
| 297 | * @param string $mode Probe mode constant. |
| 298 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 299 | * @param array $front_result Front-end probe verdict. |
| 300 | * @param array $admin_result Admin probe verdict. |
| 301 | * @return void |
| 302 | */ |
| 303 | protected function log_probe_anomaly( $mode, array $plugin_mains, array $front_result, array $admin_result ) { |
| 304 | pcg_log_event( |
| 305 | 'Probe anomaly allowed', |
| 306 | array( |
| 307 | 'mode' => $mode, |
| 308 | 'plugins' => $this->relative_basenames( $plugin_mains ), |
| 309 | 'front' => $this->probe_anomaly_label( $front_result ), |
| 310 | 'admin' => $this->probe_anomaly_label( $admin_result ), |
| 311 | ) |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * One-line label for an anomalous-allow verdict — `<status>: <reason>`. |
| 317 | * Lets a single log entry name both the class of allow (error vs |
| 318 | * ok-inconclusive) and the underlying cause (HTTP 500, redirect |
| 319 | * cycle, intercepted loopback, etc.). Reasons are author-written |
| 320 | * sentences from `parse_response` and `pcg_log_event` already caps |
| 321 | * payload size, so we don't truncate here. |
| 322 | * |
| 323 | * @param array $result Probe verdict. |
| 324 | * @return string |
| 325 | */ |
| 326 | protected function probe_anomaly_label( array $result ) { |
| 327 | if ( ! $this->is_anomalous_allow( $result ) ) { |
| 328 | return ''; |
| 329 | } |
| 330 | $status = (string) ( $result['status'] ?? '' ); |
| 331 | $reason = (string) ( $result['reason'] ?? $result['message'] ?? '' ); |
| 332 | return '' !== $reason ? $status . ': ' . $reason : $status; |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Strip `WP_PLUGIN_DIR/` from each absolute path so log entries carry |
| 337 | * the canonical plugin basename (e.g. `akismet/akismet.php`). |
| 338 | * |
| 339 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 340 | * @return string[] |
| 341 | */ |
| 342 | protected function relative_basenames( array $plugin_mains ) { |
| 343 | $prefix = WP_PLUGIN_DIR . '/'; |
| 344 | $out = array(); |
| 345 | foreach ( $plugin_mains as $path ) { |
| 346 | $out[] = str_starts_with( (string) $path, $prefix ) |
| 347 | ? substr( (string) $path, strlen( $prefix ) ) |
| 348 | : (string) $path; |
| 349 | } |
| 350 | return $out; |
| 351 | } |
| 352 | |
| 353 | /** |
| 354 | * Build the transient payload that the probe endpoint will consume. |
| 355 | * |
| 356 | * Exposed for unit tests so they can assert the stash shape without |
| 357 | * needing a live HTTP loopback. Not part of the public API. |
| 358 | * |
| 359 | * @internal |
| 360 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 361 | * @param string $mode Probe mode constant. |
| 362 | * @param bool $is_confirm Whether this payload is for a confirmation probe. |
| 363 | * @return array{plugins:string[],mode:string,confirm:bool} |
| 364 | */ |
| 365 | public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION, $is_confirm = false ) { |
| 366 | return array( |
| 367 | 'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ), |
| 368 | 'mode' => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION, |
| 369 | 'confirm' => (bool) $is_confirm, |
| 370 | ); |
| 371 | } |
| 372 | |
| 373 | /** |
| 374 | * Stash a probe transient and build the `Requests::request_multiple` |
| 375 | * descriptor for one of the two parallel probes. |
| 376 | * |
| 377 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 378 | * @param string $base_url Front-end or admin base URL. |
| 379 | * @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies. |
| 380 | * @param string $mode Probe mode constant. |
| 381 | * @param bool $is_confirm Adds `pcg_confirm=1` so the early bootstrap injects candidates into active_plugins. |
| 382 | * @return array{token:string,request:array} |
| 383 | */ |
| 384 | protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION, $is_confirm = false ) { |
| 385 | $token = wp_generate_password( 32, false ); |
| 386 | set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode, $is_confirm ), self::TOKEN_LIFETIME ); |
| 387 | |
| 388 | $query = array( |
| 389 | 'pcg_probe' => '1', |
| 390 | 'token' => $token, |
| 391 | ); |
| 392 | $headers = array(); |
| 393 | $options = array(); |
| 394 | if ( $is_confirm ) { |
| 395 | $query['pcg_confirm'] = '1'; |
| 396 | } |
| 397 | if ( $is_admin ) { |
| 398 | $query['pcg_admin'] = '1'; |
| 399 | $cookie_header = $this->collect_auth_cookie_header(); |
| 400 | if ( '' !== $cookie_header ) { |
| 401 | $headers['Cookie'] = $cookie_header; |
| 402 | $options['hooks'] = $this->build_same_host_cookie_hook( $base_url ); |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | return array( |
| 407 | 'token' => $token, |
| 408 | 'request' => array( |
| 409 | 'url' => add_query_arg( $query, $base_url ), |
| 410 | 'type' => 'GET', |
| 411 | 'headers' => $headers, |
| 412 | 'options' => $options, |
| 413 | ), |
| 414 | ); |
| 415 | } |
| 416 | |
| 417 | /** |
| 418 | * Translate a `Requests::request_multiple` response into a probe verdict. |
| 419 | * |
| 420 | * @param mixed $response A `WpOrg\Requests\Response`, or an exception |
| 421 | * thrown for that single request. |
| 422 | * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string} |
| 423 | */ |
| 424 | protected function parse_response( $response ) { |
| 425 | if ( $response instanceof \Throwable ) { |
| 426 | // Bootstrap was healthy enough to issue several redirects in |
| 427 | // a row, so treat redirect-budget exhaustion as inconclusive |
| 428 | // rather than an error. |
| 429 | if ( $response instanceof \WpOrg\Requests\Exception && 'toomanyredirects' === $response->getType() ) { |
| 430 | return array( |
| 431 | 'status' => 'ok-inconclusive', |
| 432 | 'reason' => 'Probe exceeded redirect budget; treating as inconclusive ok.', |
| 433 | ); |
| 434 | } |
| 435 | return array( |
| 436 | 'status' => 'error', |
| 437 | 'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ), |
| 438 | ); |
| 439 | } |
| 440 | |
| 441 | $code = (int) ( $response->status_code ?? 0 ); |
| 442 | $body = (string) ( $response->body ?? '' ); |
| 443 | $redirect_count = (int) ( $response->redirects ?? 0 ); |
| 444 | |
| 445 | $decoded = json_decode( $body, true ); |
| 446 | if ( is_array( $decoded ) && isset( $decoded['status'] ) ) { |
| 447 | return $decoded; |
| 448 | } |
| 449 | |
| 450 | // 3xx that Requests refused to follow (cross-scheme downgrade, |
| 451 | // malformed Location). Treat as ok — bootstrap completed enough |
| 452 | // to emit one. |
| 453 | if ( $code >= 300 && $code < 400 ) { |
| 454 | return array( |
| 455 | 'status' => 'ok-inconclusive', |
| 456 | 'reason' => sprintf( 'Probe redirected (HTTP %d); treating as inconclusive ok.', $code ), |
| 457 | ); |
| 458 | } |
| 459 | |
| 460 | // Policy: only block on a captured fatal (status=fatal from the |
| 461 | // shutdown handler's classify_shutdown, or status=throwable from |
| 462 | // the require-time catch). HTTP 500 and "marker present, no JSON |
| 463 | // verdict" are guesses at a fatal from outside the request — on |
| 464 | // real traffic, each is dominated by upstream LBs, edge proxies, |
| 465 | // intercepting plugins, engine death (segfault/OOM/FastCGI |
| 466 | // termination), or a mid-stream connection drop. Treat as |
| 467 | // `ok-inconclusive` (allow + log) so the user can proceed and |
| 468 | // `log_probe_anomaly` records the rate. |
| 469 | if ( 500 === $code ) { |
| 470 | return array( |
| 471 | 'status' => 'ok-inconclusive', |
| 472 | 'reason' => 'Probe loopback returned HTTP 500 without a JSON verdict; no captured fatal, so treating as inconclusive ok. Could be an upstream LB, edge proxy, intercepting plugin, or a real engine death we can\'t verify.', |
| 473 | ); |
| 474 | } |
| 475 | |
| 476 | if ( $code >= 200 && $code < 300 ) { |
| 477 | // Marker present + non-JSON 200: the probe endpoint ran but no |
| 478 | // verdict was written. With the shutdown handler always emitting |
| 479 | // a verdict, this branch is reachable mainly when the engine |
| 480 | // itself died (segfault, OOM kill, FastCGI process terminated) |
| 481 | // or a mid-stream connection drop / re-entry-guarded partial |
| 482 | // body. None is a captured fatal we can confidently attribute |
| 483 | // to a plugin, so allow + log per the policy above. |
| 484 | if ( $this->probe_endpoint_was_reached( $response ) ) { |
| 485 | return array( |
| 486 | 'status' => 'ok-inconclusive', |
| 487 | 'reason' => sprintf( |
| 488 | 'Probe endpoint ran but no JSON verdict was emitted (HTTP %d). No captured PHP fatal, so treating as inconclusive ok. Most likely engine death (segfault / OOM kill / process terminated) or a connection drop mid-response.', |
| 489 | $code |
| 490 | ), |
| 491 | ); |
| 492 | } |
| 493 | // No marker, but a redirect was followed: the destination dropped |
| 494 | // the probe query and landed on a clean page. Bootstrap rendered |
| 495 | // fine, so don't block. |
| 496 | if ( $redirect_count > 0 ) { |
| 497 | return array( |
| 498 | 'status' => 'ok-inconclusive', |
| 499 | 'reason' => sprintf( 'Probe followed %d redirect(s) but destination dropped the probe query; treating as inconclusive ok.', $redirect_count ), |
| 500 | ); |
| 501 | } |
| 502 | // No marker and no redirect: the loopback never reached our |
| 503 | // endpoint — a full-page/edge cache, a security plugin, or a |
| 504 | // maintenance page answered with a 200. We learned nothing about |
| 505 | // the plugin, so this is an inconclusive transport `error` (logged, |
| 506 | // non-blocking) — NOT a fatal. Blocking here would reject a |
| 507 | // perfectly healthy plugin. |
| 508 | return array( |
| 509 | 'status' => 'error', |
| 510 | 'reason' => sprintf( 'Probe loopback returned HTTP %d without reaching the PCG endpoint (cache or intercepting plugin).', $code ), |
| 511 | ); |
| 512 | } |
| 513 | |
| 514 | return array( |
| 515 | 'status' => 'error', |
| 516 | 'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ), |
| 517 | ); |
| 518 | } |
| 519 | |
| 520 | /** |
| 521 | * Whether the probe endpoint actually executed for this response. |
| 522 | * |
| 523 | * `probe-endpoint.php` sends `X-PCG-Probe: 1` the instant it recognises a |
| 524 | * probe request. Its absence means the loopback was answered by something |
| 525 | * else (cache layer, security plugin, maintenance page) before our code |
| 526 | * ran. Header lookup is case-insensitive via `Requests`' Headers object. |
| 527 | * |
| 528 | * @param \WpOrg\Requests\Response $response Probe response. |
| 529 | * @return bool |
| 530 | */ |
| 531 | protected function probe_endpoint_was_reached( $response ) { |
| 532 | return isset( $response->headers ) && isset( $response->headers['x-pcg-probe'] ); |
| 533 | } |
| 534 | |
| 535 | /** |
| 536 | * `Cookie:` header from the current request's WP auth cookies, so the |
| 537 | * admin loopback authenticates as the same user. Empty if none found. |
| 538 | * |
| 539 | * @return string |
| 540 | */ |
| 541 | protected function collect_auth_cookie_header() { |
| 542 | if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { |
| 543 | return ''; |
| 544 | } |
| 545 | $pairs = array(); |
| 546 | foreach ( $_COOKIE as $name => $value ) { |
| 547 | if ( ! is_string( $name ) || ! is_string( $value ) ) { |
| 548 | continue; |
| 549 | } |
| 550 | if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) { |
| 551 | continue; |
| 552 | } |
| 553 | $pairs[] = $name . '=' . wp_unslash( $value ); |
| 554 | } |
| 555 | return implode( '; ', $pairs ); |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * Build a Hooks instance that strips the forwarded `Cookie:` header on |
| 560 | * any redirect that leaves the original origin: off-host, or an |
| 561 | * https→http scheme downgrade on the same host. Defends against |
| 562 | * leaking admin auth cookies (we forward `Cookie:` manually, so |
| 563 | * Requests won't enforce browser `Secure` semantics for us). |
| 564 | * Relative redirects inherit the original origin and pass through. |
| 565 | * |
| 566 | * @param string $original_url Initial probe URL whose origin is the trust boundary. |
| 567 | * @return \WpOrg\Requests\Hooks |
| 568 | */ |
| 569 | protected function build_same_host_cookie_hook( $original_url ) { |
| 570 | $original = wp_parse_url( $original_url ); |
| 571 | $original_host = isset( $original['host'] ) ? strtolower( (string) $original['host'] ) : ''; |
| 572 | $original_scheme = isset( $original['scheme'] ) ? strtolower( (string) $original['scheme'] ) : ''; |
| 573 | |
| 574 | $hooks = new \WpOrg\Requests\Hooks(); |
| 575 | $hooks->register( |
| 576 | 'requests.before_redirect', |
| 577 | static function ( &$location, &$req_headers, &$req_data, &$options, $return_value ) use ( $original_host, $original_scheme ) { |
| 578 | unset( $req_data, $options, $return_value ); |
| 579 | if ( ! is_array( $req_headers ) ) { |
| 580 | return; |
| 581 | } |
| 582 | $next = wp_parse_url( (string) $location ); |
| 583 | $next_host = isset( $next['host'] ) ? strtolower( (string) $next['host'] ) : $original_host; |
| 584 | $next_scheme = isset( $next['scheme'] ) ? strtolower( (string) $next['scheme'] ) : $original_scheme; |
| 585 | $same_host = '' !== $next_host && $next_host === $original_host; |
| 586 | $scheme_downgrade = 'https' === $original_scheme && 'https' !== $next_scheme; |
| 587 | if ( ! $same_host || $scheme_downgrade ) { |
| 588 | foreach ( array_keys( $req_headers ) as $key ) { |
| 589 | if ( 0 === strcasecmp( (string) $key, 'Cookie' ) ) { |
| 590 | unset( $req_headers[ $key ] ); |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | } |
| 595 | ); |
| 596 | return $hooks; |
| 597 | } |
| 598 | |
| 599 | /** |
| 600 | * Classify a PHP shutdown into a probe verdict. Returns `fatal` only |
| 601 | * for the engine-fatal error mask; anything else becomes |
| 602 | * `ok-shutdown`, signalling that the bootstrap reached PHP shutdown |
| 603 | * without a captured fatal but didn't reach the wp_loaded/admin_init |
| 604 | * verdict point (typical of a plugin calling `exit` during init). |
| 605 | * |
| 606 | * Pure helper so the probe endpoint's shutdown handler can be |
| 607 | * exercised without firing PHP shutdown in tests. |
| 608 | * |
| 609 | * @param array|null $error Result of `error_get_last()`. |
| 610 | * @return array Probe verdict. |
| 611 | */ |
| 612 | public static function classify_shutdown( $error ) { |
| 613 | if ( is_array( $error ) && 0 !== ( ( (int) ( $error['type'] ?? 0 ) ) & self::SHUTDOWN_FATAL_MASK ) ) { |
| 614 | return array( |
| 615 | 'status' => 'fatal', |
| 616 | 'errno' => (int) $error['type'], |
| 617 | 'message' => (string) ( $error['message'] ?? '' ), |
| 618 | 'file' => (string) ( $error['file'] ?? '' ), |
| 619 | 'line' => (int) ( $error['line'] ?? 0 ), |
| 620 | ); |
| 621 | } |
| 622 | return array( |
| 623 | 'status' => 'ok-shutdown', |
| 624 | 'reason' => 'Probe reached shutdown without a captured fatal; bootstrap exited before wp_loaded/admin_init (likely a plugin-initiated exit/redirect during init).', |
| 625 | ); |
| 626 | } |
| 627 | |
| 628 | /** |
| 629 | * Transient key for a probe token. Shared with the endpoint. |
| 630 | * |
| 631 | * @param string $token Random probe token. |
| 632 | * @return string |
| 633 | */ |
| 634 | public static function transient_key( $token ) { |
| 635 | return 'pcg_probe_' . md5( (string) $token ); |
| 636 | } |
| 637 | } |