Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
22.73% |
40 / 176 |
|
28.57% |
2 / 7 |
CRAP | n/a |
0 / 0 |
|
| pcg_guard_maybe_block_activation | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
182 | |||
| pcg_guard_evaluate_plugins | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
90 | |||
| pcg_guard_log_blocked_activation | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| pcg_guard_get_blocked_plugin | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
10 | |||
| pcg_guard_format_block_reason | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
6 | |||
| pcg_guard_plugin_display_name | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| pcg_guard_render_block_notice | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Activation guard — blocks plugin activations that fail a pre-flight |
| 4 | * load probe, so a bad Activate click can't fatal the site. |
| 5 | * |
| 6 | * @package automattic/jetpack-mu-wpcom |
| 7 | */ |
| 8 | |
| 9 | add_action( 'load-plugins.php', 'pcg_guard_maybe_block_activation', 0 ); |
| 10 | add_action( 'load-update.php', 'pcg_guard_maybe_block_activation', 0 ); |
| 11 | add_action( 'admin_notices', 'pcg_guard_render_block_notice' ); |
| 12 | |
| 13 | /** |
| 14 | * Entry point on `load-plugins.php` / `load-update.php`. Probes each |
| 15 | * plugin being activated and redirects with a notice on any failure. |
| 16 | */ |
| 17 | function pcg_guard_maybe_block_activation() { |
| 18 | if ( ! apply_filters( 'pcg_guard_activation', true ) ) { |
| 19 | return; |
| 20 | } |
| 21 | if ( ! current_user_can( 'activate_plugins' ) ) { |
| 22 | return; |
| 23 | } |
| 24 | |
| 25 | // Bulk-action submissions from the bottom dropdown send `action=-1` |
| 26 | // and the real action in `action2`. |
| 27 | $action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ?? '' ) ); |
| 28 | if ( '' === $action || '-1' === $action ) { |
| 29 | $action = sanitize_text_field( wp_unslash( $_REQUEST['action2'] ?? '' ) ); |
| 30 | } |
| 31 | if ( ! in_array( $action, array( 'activate', 'activate-plugin', 'activate-selected' ), true ) ) { |
| 32 | return; |
| 33 | } |
| 34 | if ( pcg_force_override_active( 'activate_plugins' ) ) { |
| 35 | return; |
| 36 | } |
| 37 | |
| 38 | if ( 'activate-selected' === $action ) { |
| 39 | $bulk_raw = is_array( $_REQUEST['checked'] ?? null ) ? (array) wp_unslash( $_REQUEST['checked'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each entry is sanitized below. |
| 40 | $plugins_to_check = array_values( |
| 41 | array_filter( |
| 42 | array_map( static fn( $b ) => sanitize_text_field( (string) $b ), $bulk_raw ) |
| 43 | ) |
| 44 | ); |
| 45 | $nonce_action = 'bulk-plugins'; |
| 46 | } else { |
| 47 | // Single-plugin path (plugins.php Activate link / update.php post-upload link). |
| 48 | $plugin = sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ?? '' ) ); |
| 49 | $plugins_to_check = '' !== $plugin ? array( $plugin ) : array(); |
| 50 | $nonce_action = 'activate-plugin_' . $plugin; |
| 51 | } |
| 52 | if ( empty( $plugins_to_check ) ) { |
| 53 | return; |
| 54 | } |
| 55 | |
| 56 | // Verify the nonce up front so we don't run probes for a request core |
| 57 | // will reject anyway. check_admin_referer() die()s on a bad nonce, so |
| 58 | // we don't need to check its return value. |
| 59 | if ( ! isset( $_REQUEST['_wpnonce'] ) ) { |
| 60 | return; |
| 61 | } |
| 62 | check_admin_referer( $nonce_action ); |
| 63 | |
| 64 | $blocked = pcg_guard_evaluate_plugins( $plugins_to_check ); |
| 65 | if ( empty( $blocked ) ) { |
| 66 | return; |
| 67 | } |
| 68 | |
| 69 | set_transient( |
| 70 | 'pcg_guard_notice_' . get_current_user_id(), |
| 71 | $blocked, |
| 72 | MINUTE_IN_SECONDS |
| 73 | ); |
| 74 | |
| 75 | wp_safe_redirect( self_admin_url( 'plugins.php?pcg_blocked=1' ) ); |
| 76 | exit; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Probe the requested plugins together in a single loopback request pair |
| 81 | * and return a map of basename => reason for any that fail. |
| 82 | * |
| 83 | * Eligible plugins (passes `validate_file`, not already active, file |
| 84 | * exists on disk) are passed to `PCG_Load_Tester::test()` as one batch, |
| 85 | * so probe cost is constant in N rather than 2N round-trips. As a side |
| 86 | * effect this also surfaces conflicts that only fire when two plugins |
| 87 | * load together (duplicate class, shared global, etc.). |
| 88 | * |
| 89 | * @param string[] $plugins Plugin basenames (e.g. "akismet/akismet.php"). |
| 90 | * @return array<string,string> |
| 91 | */ |
| 92 | function pcg_guard_evaluate_plugins( $plugins ) { |
| 93 | $paths = array(); |
| 94 | foreach ( $plugins as $plugin ) { |
| 95 | if ( 0 !== validate_file( $plugin ) ) { |
| 96 | continue; |
| 97 | } |
| 98 | if ( is_plugin_active( $plugin ) ) { |
| 99 | continue; |
| 100 | } |
| 101 | $path = WP_PLUGIN_DIR . '/' . ltrim( $plugin, '/' ); |
| 102 | if ( ! is_file( $path ) ) { |
| 103 | continue; |
| 104 | } |
| 105 | $paths[ $plugin ] = $path; |
| 106 | } |
| 107 | if ( empty( $paths ) ) { |
| 108 | return array(); |
| 109 | } |
| 110 | |
| 111 | $tester = new PCG_Load_Tester(); |
| 112 | $result = $tester->test( array_values( $paths ) ); |
| 113 | $status = (string) ( $result['status'] ?? '' ); |
| 114 | if ( 'fatal' !== $status && 'throwable' !== $status ) { |
| 115 | return array(); |
| 116 | } |
| 117 | |
| 118 | $blocked_plugin = pcg_guard_get_blocked_plugin( $result, $paths ); |
| 119 | if ( '' !== $blocked_plugin ) { |
| 120 | $blocked = array( |
| 121 | $blocked_plugin => pcg_guard_format_block_reason( $result ), |
| 122 | ); |
| 123 | } else { |
| 124 | // Verdict didn't pin a specific plugin (e.g. probe terminated without a |
| 125 | // JSON body, or the captured `file` was outside any candidate's tree). |
| 126 | // Surface a batch-level message so we don't blame an arbitrary plugin. |
| 127 | $reason = sprintf( |
| 128 | /* translators: 1: locale-formatted list of plugin basenames; 2: probe verdict reason. */ |
| 129 | __( 'One of these plugins caused a fatal during the pre-flight check: %1$s. Reason: %2$s', 'jetpack-mu-wpcom' ), |
| 130 | wp_sprintf_l( '%l', array_keys( $paths ) ), |
| 131 | pcg_guard_format_block_reason( $result ) |
| 132 | ); |
| 133 | $blocked = array( '' => $reason ); |
| 134 | } |
| 135 | |
| 136 | pcg_guard_log_blocked_activation( array_keys( $paths ), $blocked, $result ); |
| 137 | |
| 138 | return $blocked; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Log an activation block to logstash. Best-effort. |
| 143 | * |
| 144 | * @param string[] $checked Probe batch as basenames. |
| 145 | * @param array<string,string> $blocked Map of basename => admin-notice reason. Empty-string key = batch-level fallback. |
| 146 | * @param array $result Probe verdict from PCG_Load_Tester::test(). |
| 147 | * @return void |
| 148 | */ |
| 149 | function pcg_guard_log_blocked_activation( array $checked, array $blocked, array $result ) { |
| 150 | pcg_log_event( |
| 151 | 'Activation blocked', |
| 152 | array( |
| 153 | 'checked' => $checked, |
| 154 | 'blocked' => array_keys( $blocked ), |
| 155 | 'status' => (string) ( $result['status'] ?? '' ), |
| 156 | // Basename only — absolute paths leak install layout. |
| 157 | 'file' => isset( $result['file'] ) ? basename( (string) $result['file'] ) : '', |
| 158 | 'line' => (int) ( $result['line'] ?? 0 ), |
| 159 | 'reason' => (string) ( $result['message'] ?? '' ), |
| 160 | ) |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Map a fatal/throwable verdict back to the plugin basename that caused |
| 166 | * it. Tries, in order: the explicit `plugin` field on the verdict (set |
| 167 | * when a `Throwable` was caught around `require`), an exact match of |
| 168 | * the captured `file` against a plugin's main file (covers flat-file |
| 169 | * plugins like `hello.php`), and a prefix match of the captured `file` |
| 170 | * against a plugin's own subdirectory under `WP_PLUGIN_DIR`. |
| 171 | * |
| 172 | * Returns `''` when none of those match (e.g. the verdict has no |
| 173 | * `file`/`plugin`, or `file` lies outside any candidate's tree). The |
| 174 | * caller is expected to surface a batch-level message in that case |
| 175 | * rather than guessing a plugin. |
| 176 | * |
| 177 | * @param array $result A fatal/throwable probe verdict. |
| 178 | * @param array<string,string> $paths Map of plugin basename => absolute main file path. |
| 179 | * @return string Plugin basename to attribute the failure to, or '' if undetermined. |
| 180 | */ |
| 181 | function pcg_guard_get_blocked_plugin( $result, $paths ) { |
| 182 | $explicit = (string) ( $result['plugin'] ?? '' ); |
| 183 | if ( '' !== $explicit ) { |
| 184 | foreach ( $paths as $basename => $path ) { |
| 185 | if ( $path === $explicit ) { |
| 186 | return $basename; |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | $fatal_file = (string) ( $result['file'] ?? '' ); |
| 192 | if ( '' !== $fatal_file ) { |
| 193 | foreach ( $paths as $basename => $path ) { |
| 194 | if ( $path === $fatal_file ) { |
| 195 | return $basename; |
| 196 | } |
| 197 | } |
| 198 | // Subdirectory plugins only — a flat-file plugin's dirname is |
| 199 | // `WP_PLUGIN_DIR`, which would prefix-match every other plugin's |
| 200 | // files in the batch and produce false attributions. |
| 201 | foreach ( $paths as $basename => $path ) { |
| 202 | $plugin_dir = dirname( $path ); |
| 203 | if ( WP_PLUGIN_DIR === $plugin_dir ) { |
| 204 | continue; |
| 205 | } |
| 206 | if ( str_starts_with( $fatal_file, $plugin_dir . '/' ) ) { |
| 207 | return $basename; |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | return ''; |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Build a human-readable sentence describing the captured fatal, e.g. |
| 217 | * "PCG fatal (in pcg-fatal-tester.php, line 6)." for the admin notice. |
| 218 | * |
| 219 | * @param array $result Probe result from PCG_Load_Tester::test(). |
| 220 | * @return string |
| 221 | */ |
| 222 | function pcg_guard_format_block_reason( $result ) { |
| 223 | $message = trim( (string) ( $result['message'] ?? '' ) ); |
| 224 | |
| 225 | $where = ''; |
| 226 | if ( ! empty( $result['file'] ) ) { |
| 227 | $file = basename( (string) $result['file'] ); |
| 228 | $line = (int) ( $result['line'] ?? 0 ); |
| 229 | $where = $line > 0 |
| 230 | ? sprintf( |
| 231 | /* translators: location fragment, e.g. "in plugin.php, line 42". 1: file name, 2: line number. */ |
| 232 | __( 'in %1$s, line %2$d', 'jetpack-mu-wpcom' ), |
| 233 | $file, |
| 234 | $line |
| 235 | ) |
| 236 | : sprintf( |
| 237 | /* translators: location fragment without a line number, e.g. "in plugin.php". %s: file name. */ |
| 238 | __( 'in %s', 'jetpack-mu-wpcom' ), |
| 239 | $file |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | if ( '' !== $message ) { |
| 244 | return '' !== $where ? sprintf( '%s (%s).', $message, $where ) : $message . '.'; |
| 245 | } |
| 246 | if ( '' !== $where ) { |
| 247 | return sprintf( |
| 248 | /* translators: %s: location fragment from the strings above, which already begins with "in". */ |
| 249 | __( 'A fatal PHP error was detected %s.', 'jetpack-mu-wpcom' ), |
| 250 | $where |
| 251 | ); |
| 252 | } |
| 253 | return __( 'A fatal PHP error was detected.', 'jetpack-mu-wpcom' ); |
| 254 | } |
| 255 | |
| 256 | /** |
| 257 | * Look up a plugin's human-readable Name header. Returns '' when the |
| 258 | * file is unreadable or the header is empty. |
| 259 | * |
| 260 | * @param string $basename Plugin basename (e.g. "akismet/akismet.php"). |
| 261 | * @return string |
| 262 | */ |
| 263 | function pcg_guard_plugin_display_name( $basename ) { |
| 264 | $path = WP_PLUGIN_DIR . '/' . ltrim( $basename, '/' ); |
| 265 | if ( ! is_file( $path ) ) { |
| 266 | return ''; |
| 267 | } |
| 268 | if ( ! function_exists( 'get_plugin_data' ) ) { |
| 269 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 270 | } |
| 271 | $data = get_plugin_data( $path, false, false ); |
| 272 | return isset( $data['Name'] ) ? (string) $data['Name'] : ''; |
| 273 | } |
| 274 | |
| 275 | /** |
| 276 | * Render the admin notice. Messages are pulled from a per-user transient |
| 277 | * set by the guard before the redirect. |
| 278 | */ |
| 279 | function pcg_guard_render_block_notice() { |
| 280 | if ( empty( $_GET['pcg_blocked'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for rendering a flash notice. |
| 281 | return; |
| 282 | } |
| 283 | $key = 'pcg_guard_notice_' . get_current_user_id(); |
| 284 | $messages = get_transient( $key ); |
| 285 | delete_transient( $key ); |
| 286 | |
| 287 | if ( ! is_array( $messages ) || empty( $messages ) ) { |
| 288 | return; |
| 289 | } |
| 290 | ?> |
| 291 | <div class="notice notice-error"> |
| 292 | <p><strong><?php esc_html_e( 'WordPress.com blocked activation because the pre-flight check detected a fatal:', 'jetpack-mu-wpcom' ); ?></strong></p> |
| 293 | <ul style="list-style:disc;padding-inline-start:24px;"> |
| 294 | <?php foreach ( $messages as $plugin => $reason ) : ?> |
| 295 | <li> |
| 296 | <?php if ( '' !== (string) $plugin ) : ?> |
| 297 | <code><?php echo esc_html( $plugin ); ?></code> — <?php echo esc_html( $reason ); ?> |
| 298 | <?php else : ?> |
| 299 | <?php echo esc_html( $reason ); ?> |
| 300 | <?php endif; ?> |
| 301 | </li> |
| 302 | <?php endforeach; ?> |
| 303 | </ul> |
| 304 | <p><?php esc_html_e( 'No plugins were activated to prevent a site crash. Investigate the error before trying again, or:', 'jetpack-mu-wpcom' ); ?></p> |
| 305 | <ul style="list-style:disc;padding-inline-start:24px;margin-block-end:0;"> |
| 306 | <?php foreach ( $messages as $plugin => $reason ) : ?> |
| 307 | <?php |
| 308 | if ( '' === (string) $plugin ) { |
| 309 | continue; |
| 310 | } |
| 311 | $retry_url = wp_nonce_url( |
| 312 | add_query_arg( |
| 313 | array( |
| 314 | 'action' => 'activate', |
| 315 | 'plugin' => $plugin, |
| 316 | 'pcg_force' => '1', |
| 317 | ), |
| 318 | self_admin_url( 'plugins.php' ) |
| 319 | ), |
| 320 | 'activate-plugin_' . $plugin |
| 321 | ); |
| 322 | $plugin_name = pcg_guard_plugin_display_name( $plugin ); |
| 323 | ?> |
| 324 | <li> |
| 325 | <a href="<?php echo esc_url( $retry_url ); ?>" class="button-link"> |
| 326 | <?php |
| 327 | if ( '' !== $plugin_name ) { |
| 328 | printf( |
| 329 | /* translators: %s: plugin display name. */ |
| 330 | esc_html__( 'Activate %s anyway', 'jetpack-mu-wpcom' ), |
| 331 | esc_html( $plugin_name ) |
| 332 | ); |
| 333 | } else { |
| 334 | esc_html_e( 'Activate anyway', 'jetpack-mu-wpcom' ); |
| 335 | } |
| 336 | ?> |
| 337 | </a> |
| 338 | </li> |
| 339 | <?php endforeach; ?> |
| 340 | <li><?php pcg_force_render_bypass_form(); ?></li> |
| 341 | </ul> |
| 342 | </div> |
| 343 | <?php |
| 344 | } |