Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade/Install: Roll back a plugin auto-update with a fatal error. #5287

Closed
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0e7f8df
Introduce `WP_Upgrader::fatal_error_detected()`.
Sep 23, 2023
f7979fb
Roll back a plugin auto-update with a fatal error.
Sep 23, 2023
83c437d
Update auto-update email with rollback note.
Sep 23, 2023
e7daa70
Move checks into `WP_Automatic_Updater`.
Sep 28, 2023
be2425d
Add `$temp_backups` parameter to `WP_Upgrader::restore_temp_backup()`.
Sep 28, 2023
cdbedb4
Add `$temp_backups` parameter to `WP_Upgrader::delete_temp_backup()`.
Sep 28, 2023
6477f17
Ensure the filesystem is initialized in `WP_Upgrader::maintenance_mod…
Sep 28, 2023
cf1e1e8
Use `::$temp_backups` in `::delete_temp_backup()`.
Oct 1, 2023
5ff5a2f
Don't output feedback during automatic updates.
Oct 1, 2023
26d45e4
Add logging during scrapes for testing.
Oct 1, 2023
e67fd58
Move the logic from `::run()` to `::update()`.
Oct 1, 2023
cf38345
Remove potentially inadequate time limit.
Oct 1, 2023
798cd0f
Restore the scrape key timeout to 60 seconds.
Oct 1, 2023
aacaf77
Add latest updates.
Oct 14, 2023
d13ec5d
Do not enable maintenance mode while scraping.
Oct 14, 2023
c7a46a8
Update maintenance mode locations.
Oct 14, 2023
b648aa0
Rename `$r` and add an inline comment.
Oct 14, 2023
b9eeb6f
Harden the maintenance mode bypass while scraping.
costdev Feb 17, 2024
3f065e4
Change hardening approach for maintenance mode bypass.
costdev Mar 9, 2024
a1f7436
Update `@since` tags.
costdev Mar 9, 2024
13a69be
Apply suggestions from code review
costdev Apr 3, 2024
7a8a3f6
PHPCS fixes
costdev Apr 3, 2024
d2cc1d4
Simplify the return for loopback failure.
costdev Apr 15, 2024
6529094
Improve spacing.
costdev Apr 16, 2024
c1164e6
Update src/wp-admin/includes/class-wp-upgrader.php
costdev May 2, 2024
fa24191
Update src/wp-admin/includes/class-wp-upgrader.php
costdev May 8, 2024
7b28560
Update src/wp-admin/includes/class-wp-automatic-updater.php
johnbillion May 8, 2024
ab3e30f
Merge branch 'trunk' into rollback_automatic_plugin_updates
johnbillion May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 201 additions & 1 deletion src/wp-admin/includes/class-wp-automatic-updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,29 @@ public function update( $type, $item ) {
$allow_relaxed_file_ownership = true;
}

if ( 'plugin' === $type ) {
$was_active = is_plugin_active( $upgrader_item );
error_log( ' Upgrading plugin ' . var_export( $item->slug, true ) . '...' );
}

if ( 'theme' === $type ) {
error_log( ' Upgrading theme ' . var_export( $item->theme, true ) . '...' );
}

/*
* Enable maintenance mode before upgrading the plugin.
*
* This avoids potential non-fatal errors being detected
* while scraping for a fatal error if some files are still
* being moved.
*
* While these checks are intended only for plugins,
* maintenance mode is enabled for all upgrade types as any
* update could contain an error or warning, which could cause
* the scrape to miss a fatal error in the plugin update.
*/
$upgrader->maintenance_mode( true );

// Boom, this site's about to get a whole new splash of paint!
$upgrade_result = $upgrader->upgrade(
$upgrader_item,
Expand All @@ -460,6 +483,17 @@ public function update( $type, $item ) {
)
);

/*
* After WP_Upgrader::upgrade() completes, maintenance mode is disabled.
*
* Re-enable maintenance mode while attempting to detect fatal errors
* and potentially rolling back.
*
* This avoids errors if the site is visited while fatal errors exist
* or while files are still being moved.
*/
$upgrader->maintenance_mode( true );

// If the filesystem is unavailable, false is returned.
if ( false === $upgrade_result ) {
$upgrade_result = new WP_Error( 'fs_unavailable', __( 'Could not access filesystem.' ) );
Expand All @@ -486,6 +520,86 @@ public function update( $type, $item ) {
}
}

if ( 'theme' === $type ) {
error_log( ' Theme ' . var_export( $item->theme, true ) . ' has been upgraded.' );
}

if ( 'plugin' === $type ) {
error_log( ' Plugin ' . var_export( $item->slug, true ) . ' has been upgraded.' );
if ( is_plugin_inactive( $upgrader_item ) ) {
error_log( ' ' . var_export( $upgrader_item, true ) . ' is inactive and will not be checked for fatal errors.' );
}

if ( $was_active && ! is_wp_error( $upgrade_result ) ) {

/*
* The usual time limit is five minutes. However, as a loopback request
* is about to be performed, increase the time limit to account for this.
*/
if ( function_exists( 'set_time_limit' ) ) {
set_time_limit( 10 * MINUTE_IN_SECONDS );
}
johnbillion marked this conversation as resolved.
Show resolved Hide resolved

// Avoid a race condition when there are 2 sequential plugins that have fatal errors.
johnbillion marked this conversation as resolved.
Show resolved Hide resolved
sleep( 2 );
johnbillion marked this conversation as resolved.
Show resolved Hide resolved

if ( $this->has_fatal_error() ) {
$upgrade_result = new WP_Error();
$temp_backup = array(
array(
'dir' => 'plugins',
'slug' => $item->slug,
'src' => WP_PLUGIN_DIR,
),
);

$backup_restored = $upgrader->restore_temp_backup( $temp_backup );
if ( is_wp_error( $backup_restored ) ) {
$upgrade_result->add(
'plugin_update_fatal_error_rollback_failed',
sprintf(
/* translators: %s: The plugin's slug. */
__( "The update for '%s' contained a fatal error. The previously installed version could not be restored." ),
$item->slug
audrasjb marked this conversation as resolved.
Show resolved Hide resolved
)
);

$upgrade_result->merge_from( $backup_restored );
} else {
$upgrade_result->add(
'plugin_update_fatal_error_rollback_successful',
sprintf(
/* translators: %s: The plugin's slug. */
__( "The update for '%s' contained a fatal error. The previously installed version has been restored." ),
audrasjb marked this conversation as resolved.
Show resolved Hide resolved
$item->slug
)
);

$backup_deleted = $upgrader->delete_temp_backup( $temp_backup );
if ( is_wp_error( $backup_deleted ) ) {
$upgrade_result->merge_from( $backup_deleted );
}
}

/*
* Should emails not be working, log the message(s) so that
* the log file contains context for the fatal error,
* and whether a rollback was performed.
*
* `trigger_error()` is not used as it outputs a stack trace
* to this location rather than to the fatal error, which will
* appear above this entry in the log file.
*/
error_log( ' ' . implode( "\n", $upgrade_result->get_error_messages() ) );
} else {
error_log( ' The update for ' . var_export( $item->slug, true ) . ' has no fatal errors.' );
audrasjb marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

// All processes are complete. Allow visitors to browse the site again.
$upgrader->maintenance_mode( false );

$this->update_results[ $type ][] = (object) array(
'item' => $item,
'result' => $upgrade_result,
Expand Down Expand Up @@ -514,6 +628,8 @@ public function run() {
return;
}

error_log( 'Automatic updates starting...' );

// Don't automatically run these things, as we'll handle it ourselves.
remove_action( 'upgrader_process_complete', array( 'Language_Pack_Upgrader', 'async_upgrade' ), 20 );
remove_action( 'upgrader_process_complete', 'wp_version_check' );
Expand All @@ -524,24 +640,35 @@ public function run() {
wp_update_plugins(); // Check for plugin updates.
$plugin_updates = get_site_transient( 'update_plugins' );
if ( $plugin_updates && ! empty( $plugin_updates->response ) ) {
error_log( ' Automatic plugin updates starting...' );
audrasjb marked this conversation as resolved.
Show resolved Hide resolved

foreach ( $plugin_updates->response as $plugin ) {
$this->update( 'plugin', $plugin );
}

// Force refresh of plugin update information.
wp_clean_plugins_cache();

error_log( ' Automatic plugin updates complete.' );
}

// Next, those themes we all love.
wp_update_themes(); // Check for theme updates.
$theme_updates = get_site_transient( 'update_themes' );
if ( $theme_updates && ! empty( $theme_updates->response ) ) {
error_log( ' Automatic theme updates starting...' );

foreach ( $theme_updates->response as $theme ) {
$this->update( 'theme', (object) $theme );
}
// Force refresh of theme update information.
wp_clean_themes_cache();

error_log( ' Automatic theme updates complete.' );
}

error_log( 'Automatic updates complete.' );

// Next, process any core update.
wp_version_check(); // Check for core updates.
$core_update = find_core_auto_update();
Expand Down Expand Up @@ -1163,7 +1290,7 @@ protected function send_plugin_theme_email( $type, $successful_updates, $failed_

// List failed plugin updates.
if ( ! empty( $failed_updates['plugin'] ) ) {
$body[] = __( 'These plugins failed to update:' );
$body[] = __( 'The following plugins failed to update. If there was a fatal error in the update, the previously installed version has been restored.' );

foreach ( $failed_updates['plugin'] as $item ) {
$body_message = '';
Expand Down Expand Up @@ -1551,4 +1678,77 @@ protected function send_debug_email() {

wp_mail( $email['to'], wp_specialchars_decode( $email['subject'] ), $email['body'], $email['headers'] );
}

/**
* Performs a loopback request to check for potential fatal errors.
*
* Fatal errors cannot be detected unless maintenance mode is enabled.
*
* @since 6.6.0
*
* @global int $upgrading The Unix timestamp marking when upgrading WordPress began.
*
* @return bool Whether a fatal error was detected.
*/
protected function has_fatal_error() {
global $upgrading;

$maintenance_file = ABSPATH . '.maintenance';
if ( ! file_exists( $maintenance_file ) ) {
return false;
}

require $maintenance_file;
if ( ! is_int( $upgrading ) ) {
return false;
}

$scrape_key = md5( $upgrading );
$scrape_nonce = (string) $upgrading;
$transient = 'scrape_key_' . $scrape_key;
set_transient( $transient, $scrape_nonce, 30 );

$cookies = wp_unslash( $_COOKIE );
$scrape_params = array(
'wp_scrape_key' => $scrape_key,
'wp_scrape_nonce' => $scrape_nonce,
);
$headers = array(
'Cache-Control' => 'no-cache',
);

/** This filter is documented in wp-includes/class-wp-http-streams.php */
$sslverify = apply_filters( 'https_local_ssl_verify', false );

// Include Basic auth in the loopback request.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
}

// Time to wait for loopback request to finish.
$timeout = 50; // 50 seconds.

error_log( ' Scraping home page...' );

$needle_start = "###### wp_scraping_result_start:$scrape_key ######";
$needle_end = "###### wp_scraping_result_end:$scrape_key ######";
$url = add_query_arg( $scrape_params, home_url( '/' ) );
johnbillion marked this conversation as resolved.
Show resolved Hide resolved
$response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );

costdev marked this conversation as resolved.
Show resolved Hide resolved
// If this outputs `true` in the log, it means there were no fatal errors detected.
error_log( var_export( substr( $response['body'], strpos( $response['body'], '###### wp_scraping_result_start:' ) ), true ) );

$body = wp_remote_retrieve_body( $response );
$scrape_result_position = strpos( $body, $needle_start );
$result = null;

if ( false !== $scrape_result_position ) {
$error_output = substr( $body, $scrape_result_position + strlen( $needle_start ) );
$error_output = substr( $error_output, 0, strpos( $error_output, $needle_end ) );
$result = json_decode( trim( $error_output ), true );
}
delete_transient( $transient );
johnbillion marked this conversation as resolved.
Show resolved Hide resolved
// Only fatal errors will result in a 'type' key.
return isset( $result['type'] );
}
}
53 changes: 46 additions & 7 deletions src/wp-admin/includes/class-wp-upgrader.php
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ public function run( $options ) {
$this->skin->set_result( $result );

if ( is_wp_error( $result ) ) {
// An automatic plugin update will have already performed its rollback.
if ( ! empty( $options['hook_extra']['temp_backup'] ) ) {
$this->temp_restores[] = $options['hook_extra']['temp_backup'];

Expand All @@ -905,8 +906,12 @@ public function run( $options ) {
* Actions running on `shutdown` are immune to PHP timeouts,
* so in case the failure was due to a PHP timeout,
* it will still be able to properly restore the previous version.
*
* Zero arguments are accepted as a string can sometimes be passed
* internally during actions, causing an error because
* `WP_Upgrader::restore_temp_backup()` expects an array.
*/
add_action( 'shutdown', array( $this, 'restore_temp_backup' ) );
add_action( 'shutdown', array( $this, 'restore_temp_backup' ), 10, 0 );
}
$this->skin->error( $result );

Expand Down Expand Up @@ -979,15 +984,25 @@ public function run( $options ) {
*/
public function maintenance_mode( $enable = false ) {
global $wp_filesystem;

if ( ! $wp_filesystem ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}

$file = $wp_filesystem->abspath() . '.maintenance';
if ( $enable ) {
$this->skin->feedback( 'maintenance_start' );
if ( ! wp_doing_cron() ) {
$this->skin->feedback( 'maintenance_start' );
}
// Create maintenance file to signal that we are upgrading.
$maintenance_string = '<?php $upgrading = ' . time() . '; ?>';
$wp_filesystem->delete( $file );
$wp_filesystem->put_contents( $file, $maintenance_string, FS_CHMOD_FILE );
} elseif ( ! $enable && $wp_filesystem->exists( $file ) ) {
$this->skin->feedback( 'maintenance_end' );
if ( ! wp_doing_cron() ) {
$this->skin->feedback( 'maintenance_end' );
}
$wp_filesystem->delete( $file );
}
}
Expand Down Expand Up @@ -1129,17 +1144,29 @@ public function move_to_temp_backup_dir( $args ) {
* Restores the plugin or theme from temporary backup.
*
* @since 6.3.0
* @since 6.6.0 Added the `$temp_backups` parameter.
*
* @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
*
* @param array[] $temp_backups {
* Optional. An array of temporary backups.
*
* @type string $dir The temporary backup location in the upgrade-temp-backup directory.
* @type string $slug The item's slug.
* @type string $src The directory where the original is stored. For example, `WP_PLUGIN_DIR`.
costdev marked this conversation as resolved.
Show resolved Hide resolved
* }
* @return bool|WP_Error True on success, false on early exit, otherwise WP_Error.
*/
public function restore_temp_backup() {
public function restore_temp_backup( array $temp_backups = array() ) {
global $wp_filesystem;

$errors = new WP_Error();

foreach ( $this->temp_restores as $args ) {
if ( empty( $temp_backups ) ) {
$temp_backups = $this->temp_restores;
}

foreach ( $temp_backups as $args ) {
if ( empty( $args['slug'] ) || empty( $args['src'] ) || empty( $args['dir'] ) ) {
return false;
}
Expand Down Expand Up @@ -1182,17 +1209,29 @@ public function restore_temp_backup() {
* Deletes a temporary backup.
*
* @since 6.3.0
* @since 6.6.0 Added the `$temp_backups` parameter.
*
* @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
*
* @param array[] $temp_backups {
* Optional. An array of temporary backups.
*
* @type string $dir The temporary backup location in the upgrade-temp-backup directory.
* @type string $slug The item's slug.
* @type string $src The directory where the original is stored. For example, `WP_PLUGIN_DIR`.
costdev marked this conversation as resolved.
Show resolved Hide resolved
* }
* @return bool|WP_Error True on success, false on early exit, otherwise WP_Error.
*/
public function delete_temp_backup() {
public function delete_temp_backup( array $temp_backups = array() ) {
global $wp_filesystem;

$errors = new WP_Error();

foreach ( $this->temp_backups as $args ) {
if ( empty( $temp_backups ) ) {
$temp_backups = $this->temp_backups;
}

foreach ( $temp_backups as $args ) {
if ( empty( $args['slug'] ) || empty( $args['dir'] ) ) {
return false;
}
Expand Down
10 changes: 10 additions & 0 deletions src/wp-includes/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,16 @@ function wp_is_maintenance_mode() {
return false;
}

// Don't enable maintenance mode while scraping for fatal errors.
if ( is_int( $upgrading ) && isset( $_REQUEST['wp_scrape_key'], $_REQUEST['wp_scrape_nonce'] ) ) {
$key = stripslashes( $_REQUEST['wp_scrape_key'] );
$nonce = stripslashes( $_REQUEST['wp_scrape_nonce'] );

if ( md5( $upgrading ) === $key && (int) $nonce === $upgrading ) {
return false;
}
}

/**
* Filters whether to enable maintenance mode.
*
Expand Down
Loading