Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.73% covered (danger)
22.73%
40 / 176
28.57% covered (danger)
28.57%
2 / 7
CRAP
n/a
0 / 0
pcg_guard_maybe_block_activation
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
pcg_guard_evaluate_plugins
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
pcg_guard_log_blocked_activation
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
pcg_guard_get_blocked_plugin
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
pcg_guard_format_block_reason
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
pcg_guard_plugin_display_name
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
pcg_guard_render_block_notice
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
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
9add_action( 'load-plugins.php', 'pcg_guard_maybe_block_activation', 0 );
10add_action( 'load-update.php', 'pcg_guard_maybe_block_activation', 0 );
11add_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 */
17function 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 */
92function 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 */
149function 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 */
181function 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 */
222function 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 */
263function 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 */
279function 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}