WordPress.org

Make WordPress Core

Ticket #46130: 46130.1.diff

File 46130.1.diff, 26.5 KB (added by TimothyBlynJacobs, 18 months ago)
  • src/wp-admin/includes/class-wp-plugins-list-table.php

    diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php
    index a07945a973..ff471650ea 100644
    a b class WP_Plugins_List_Table extends WP_List_Table { 
    791791                        $class .= ' update';
    792792                }
    793793
    794                 $paused                        = is_plugin_paused( $plugin_file );
    795                 $paused_on_network_sites_count = $screen->in_admin( 'network' ) ? count_paused_plugin_sites_for_network( $plugin_file ) : 0;
    796                 if ( $paused || $paused_on_network_sites_count ) {
     794                $paused = is_plugin_paused( $plugin_file );
     795
     796                if ( $paused ) {
    797797                        $class .= ' paused';
    798798                }
    799799
    class WP_Plugins_List_Table extends WP_List_Table { 
    885885
    886886                                        echo '</div>';
    887887
    888                                         if ( $paused || $paused_on_network_sites_count ) {
    889                                                 $notice_text = __( 'This plugin failed to load properly and was paused within the admin backend.' );
    890                                                 if ( $screen->in_admin( 'network' ) && $paused_on_network_sites_count ) {
    891                                                         $notice_text = sprintf(
    892                                                                 /* translators: %s: number of sites */
    893                                                                 _n( 'This plugin failed to load properly and was paused within the admin backend for %s site.', 'This plugin failed to load properly and was paused within the admin backend for %s sites.', $paused_on_network_sites_count ),
    894                                                                 number_format_i18n( $paused_on_network_sites_count )
    895                                                         );
    896                                                 }
     888                                        if ( $paused ) {
     889                                                $notice_text = __( 'This plugin failed to load properly and is paused during recovery mode.' );
    897890
    898891                                                printf( '<p><span class="dashicons dashicons-warning"></span> <strong>%s</strong></p>', $notice_text );
    899892
    class WP_Plugins_List_Table extends WP_List_Table { 
    911904
    912905                                                        $error['type'] = $core_errors[ $error['type'] ];
    913906
     907                                                        if ( empty( $error['wp_is_protected'] ) ) {
     908                                                                /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
     909                                                                $error_message = __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' );
     910                                                        } else {
     911                                                                /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
     912                                                                $error_message = __( 'The plugin caused an error in the <strong>admin backend</strong> of type %1$s in line %2$s of the file %3$s. Error message: %4$s' );
     913                                                        }
     914
    914915                                                        printf(
    915916                                                                '<div class="error-display"><p>%s</p></div>',
    916917                                                                sprintf(
    917                                                                         /* translators: 1: error type, 2: error line number, 3: error file name, 4: error message */
    918                                                                         __( 'The plugin caused an error of type %1$s in line %2$s of the file %3$s. Error message: %4$s' ),
     918                                                                        $error_message,
    919919                                                                        "<code>{$error['type']}</code>",
    920920                                                                        "<code>{$error['line']}</code>",
    921921                                                                        "<code>{$error['file']}</code>",
  • src/wp-includes/class-wp-fatal-error-handler.php

    diff --git a/src/wp-includes/class-wp-fatal-error-handler.php b/src/wp-includes/class-wp-fatal-error-handler.php
    index 0299b67a48..45abde34a3 100644
    a b class WP_Fatal_Error_Handler { 
    4040
    4141                        // If the error was stored and thus the extension paused,
    4242                        // redirect the request to catch multiple errors in one go.
    43                         if ( $this->store_error( $error ) ) {
     43                        if ( $this->store_error( $error ) && wp_is_recovery_mode() ) {
    4444                                $this->redirect_protected();
    4545                        }
    4646
     47                        maybe_send_recovery_mode_email();
     48
    4749                        // Display the PHP error template.
    4850                        $this->display_error_template();
    4951                } catch ( Exception $e ) {
    class WP_Fatal_Error_Handler { 
    8385         * @return bool True if the error was stored successfully, false otherwise.
    8486         */
    8587        protected function store_error( $error ) {
    86                 // Do not pause extensions if they only crash on a non-protected endpoint.
    87                 if ( ! is_protected_endpoint() ) {
    88                         return false;
    89                 }
    90 
    9188                return wp_record_extension_error( $error );
    9289        }
    9390
    class WP_Fatal_Error_Handler { 
    10299         * @since 5.1.0
    103100         */
    104101        protected function redirect_protected() {
    105                 // Do not redirect requests on non-protected endpoints.
    106                 if ( ! is_protected_endpoint() ) {
    107                         return;
    108                 }
    109 
    110102                // Pluggable is usually loaded after plugins, so we manually include it here for redirection functionality.
    111103                if ( ! function_exists( 'wp_redirect' ) ) {
    112104                        include ABSPATH . WPINC . '/pluggable.php';
    class WP_Fatal_Error_Handler { 
    171163                        'response' => 500,
    172164                        'exit'     => false,
    173165                );
    174                 if ( function_exists( 'admin_url' ) ) {
    175                         $args['link_url']  = admin_url();
    176                         $args['link_text'] = __( 'Log into the admin backend to fix this.' );
     166                if ( function_exists( 'wp_login_url' ) ) {
     167                        $args['link_url']  = get_recovery_mode_request_url();
     168                        $args['link_text'] = __( 'Request a Recovery Mode email to fix this.' );
    177169                }
    178170
    179171                /**
  • src/wp-includes/default-constants.php

    diff --git a/src/wp-includes/default-constants.php b/src/wp-includes/default-constants.php
    index 1d3fd5df98..0a71a91f26 100644
    a b function wp_cookie_constants() { 
    302302        if ( ! defined( 'COOKIE_DOMAIN' ) ) {
    303303                define( 'COOKIE_DOMAIN', false );
    304304        }
     305
     306        /**
     307         * @since 5.1.0
     308         */
     309        define( 'RECOVERY_MODE_COOKIE', 'wordpress_rec_' . COOKIEHASH );
    305310}
    306311
    307312/**
  • src/wp-includes/error-protection.php

    diff --git a/src/wp-includes/error-protection.php b/src/wp-includes/error-protection.php
    index c9b5740960..e045efde57 100644
    a b function wp_record_extension_error( $error ) { 
    8888        $parts     = explode( '/', $path );
    8989        $extension = array_shift( $parts );
    9090
     91        $error['wp_is_protected'] = is_protected_endpoint();
     92
    9193        return call_user_func( $callback )->record( $extension, $error );
    9294}
    9395
  • src/wp-includes/load.php

    diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php
    index 7fe5566e7e..6951845f2a 100644
    a b function wp_get_active_and_valid_plugins() { 
    701701         * Remove plugins from the list of active plugins when we're on an endpoint
    702702         * that should be protected against WSODs and the plugin is paused.
    703703         */
    704         if ( is_protected_endpoint() ) {
     704        if ( wp_is_recovery_mode() ) {
    705705                $plugins = wp_skip_paused_plugins( $plugins );
    706706        }
    707707
    function wp_get_active_and_valid_themes() { 
    766766         * Remove themes from the list of active themes when we're on an endpoint
    767767         * that should be protected against WSODs and the theme is paused.
    768768         */
    769         if ( is_protected_endpoint() ) {
     769        if ( wp_is_recovery_mode() ) {
    770770                $themes = wp_skip_paused_themes( $themes );
    771771
    772772                // If no active and valid themes exist, skip loading themes.
    function wp_using_themes() { 
    12891289        return apply_filters( 'wp_using_themes', defined( 'WP_USE_THEMES' ) && WP_USE_THEMES );
    12901290}
    12911291
     1292/**
     1293 * Is WordPress in Recovery Mode.
     1294 *
     1295 * In this mode, plugins or themes that cause WSODs will be paused.
     1296 *
     1297 * @since 5.1.0
     1298 *
     1299 * @return bool
     1300 */
     1301function wp_is_recovery_mode() {
     1302        /**
     1303         * Filters whether WordPress is in Recovery Mode.
     1304         *
     1305         * @since 5.1.0
     1306         *
     1307         * @param bool $wp_is_recovery_mode Whether WordPress is in recovery mode.
     1308         */
     1309        return apply_filters( 'wp_is_recovery_mode', defined( 'WP_RECOVERY_MODE' ) && WP_RECOVERY_MODE );
     1310}
     1311
     1312/**
     1313 * Create a recovery mode key for a user.
     1314 *
     1315 * @since 5.1.0
     1316 *
     1317 * @global PasswordHash $wp_hasher
     1318 *
     1319 * @return string Recovery mode key.
     1320 */
     1321function generate_and_store_recovery_mode_key() {
     1322
     1323        global $wp_hasher;
     1324
     1325        if ( ! function_exists( 'wp_generate_password' ) ) {
     1326                require_once ABSPATH . WPINC . '/pluggable.php';
     1327        }
     1328
     1329        $key = wp_generate_password( 20, false );
     1330
     1331        /**
     1332         * Fires when a recovery mode key is generated for a user.
     1333         *
     1334         * @since 5.1.0
     1335         *
     1336         * @param string  $key  The recovery mode key.
     1337         */
     1338        do_action( 'generate_recovery_mode_key', $key );
     1339
     1340        if ( empty( $wp_hasher ) ) {
     1341                require_once ABSPATH . WPINC . '/class-phpass.php';
     1342                $wp_hasher = new PasswordHash( 8, true );
     1343        }
     1344
     1345        $hashed = $wp_hasher->HashPassword( $key );
     1346
     1347        update_site_option( 'recovery_key', array(
     1348                'hashed_key' => $hashed,
     1349                'created_at' => time(),
     1350        ) );
     1351
     1352        return $key;
     1353}
     1354
     1355/**
     1356 * Verify if the recovery mode key is correct.
     1357 *
     1358 * @since 5.1.0
     1359 *
     1360 * @param string $key The unhashed key.
     1361 *
     1362 * @return true|WP_Error
     1363 */
     1364function validate_recovery_mode_key( $key ) {
     1365
     1366        $record = get_site_option( 'recovery_key' );
     1367
     1368        if ( ! $record ) {
     1369                return new WP_Error( 'no_recovery_key_set', __( 'Recovery Mode not initialized.' ) );
     1370        }
     1371
     1372        if ( ! is_array( $record ) || ! isset( $record['hashed_key'], $record['created_at'] ) ) {
     1373                return new WP_Error( 'invalid_recovery_key_format', __( 'Invalid recovery key format.' ) );
     1374        }
     1375
     1376        if ( ! function_exists( 'wp_check_password' ) ) {
     1377                require_once ABSPATH . WPINC . '/pluggable.php';
     1378        }
     1379
     1380        if ( ! wp_check_password( $key, $record['hashed_key'] ) ) {
     1381                return new WP_Error( 'hash_mismatch', __( 'Invalid recovery key.' ) );
     1382        }
     1383
     1384        $valid_for = HOUR_IN_SECONDS;
     1385
     1386        if ( time() > $record['created_at'] + $valid_for ) {
     1387                return new WP_Error( 'key_expired', __( 'Recovery key expired.' ) );
     1388        }
     1389
     1390        return true;
     1391}
     1392
     1393/**
     1394 * A form of `wp_hash()` specific to Recovery Mode.
     1395 *
     1396 * We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded,
     1397 * which is too late to verify the recovery mode cookie.
     1398 *
     1399 * This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored.
     1400 *
     1401 * @param string $data
     1402 *
     1403 * @return string|false
     1404 */
     1405function recovery_mode_hash( $data ) {
     1406
     1407        if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === 'put your unique phrase here' ) {
     1408                $auth_key = get_site_option( 'recovery_mode_auth_key' );
     1409
     1410                if ( ! $auth_key ) {
     1411                        if ( ! function_exists( 'wp_generate_password' ) ) {
     1412                                require_once ABSPATH . WPINC . '/pluggable.php';
     1413                        }
     1414
     1415                        $auth_key = wp_generate_password( 64, true, true );
     1416                        update_site_option( 'recovery_mode_auth_key', $auth_key );
     1417                }
     1418        } else {
     1419                $auth_key = AUTH_KEY;
     1420        }
     1421
     1422        if ( ! defined( 'AUTH_SALT' ) || 'put your unique phrase here' === AUTH_SALT || $auth_key === AUTH_SALT ) {
     1423                $auth_salt = get_site_option( 'recovery_mode_auth_salt' );
     1424
     1425                if ( ! $auth_salt ) {
     1426                        if ( ! function_exists( 'wp_generate_password' ) ) {
     1427                                require_once ABSPATH . WPINC . '/pluggable.php';
     1428                        }
     1429
     1430                        $auth_salt = wp_generate_password( 64, true, true );
     1431                        update_site_option( 'recovery_mode_auth_salt', $auth_salt );
     1432                }
     1433        } else {
     1434                $auth_salt = AUTH_SALT;
     1435        }
     1436
     1437        $secret = $auth_key . $auth_salt;
     1438
     1439        return hash_hmac( 'sha1', $data, $secret );
     1440}
     1441
     1442/**
     1443 * Generate the recovery mode cookie value.
     1444 *
     1445 * @since 5.1.0
     1446 *
     1447 * @return string
     1448 */
     1449function generate_recovery_mode_cookie() {
     1450
     1451        if ( ! function_exists( 'wp_generate_password' ) ) {
     1452                require_once ABSPATH . WPINC . '/pluggable.php';
     1453        }
     1454
     1455        $to_sign = sprintf( 'recovery_mode|%s|%s', time(), wp_generate_password( 20, false ) );
     1456        $signed  = recovery_mode_hash( $to_sign );
     1457
     1458        return base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     1459}
     1460
     1461/**
     1462 * Set the recovery mode cookie.
     1463 *
     1464 * @since 5.1.0
     1465 */
     1466function set_recovery_mode_cookie() {
     1467
     1468        $value = generate_recovery_mode_cookie();
     1469
     1470        setcookie( RECOVERY_MODE_COOKIE, $value, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
     1471
     1472        if ( COOKIEPATH !== SITECOOKIEPATH ) {
     1473                setcookie( RECOVERY_MODE_COOKIE, $value, 0, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );
     1474        }
     1475}
     1476
     1477/**
     1478 * Clear the recovery mode cookie.
     1479 *
     1480 * @sicne 5.1.0
     1481 */
     1482function clear_recovery_mode_cookie() {
     1483        setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
     1484        setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );
     1485}
     1486
     1487/**
     1488 * Validate the recovery mode cookie.
     1489 *
     1490 * @since 5.1.0
     1491 *
     1492 * @param string $cookie Optionally, specify the cookie value instead of fetching from the super global.
     1493 *
     1494 * @return true|WP_Error
     1495 */
     1496function validate_recovery_mode_cookie( $cookie = '' ) {
     1497
     1498        if ( ! $cookie ) {
     1499                if ( empty( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) {
     1500                        return new WP_Error( 'no_cookie' );
     1501                }
     1502
     1503                $cookie = $_COOKIE[ RECOVERY_MODE_COOKIE ];
     1504        }
     1505
     1506        $cookie = base64_decode( $cookie );
     1507        $parts  = explode( '|', $cookie );
     1508
     1509        if ( 4 !== count( $parts ) ) {
     1510                return new WP_Error( 'invalid_format', __( 'Invalid cookie format.' ) );
     1511        }
     1512
     1513        list( , $created_at, $random, $signature ) = $parts;
     1514
     1515        if ( ! ctype_digit( $created_at ) ) {
     1516                return new WP_Error( 'invalid_created_at', __( 'Invalid cookie format.' ) );
     1517        }
     1518
     1519        /**
     1520         * Filter the length of time a Recovery Mode cookie is valid for.
     1521         *
     1522         * @since 5.1.0
     1523         *
     1524         * @param int $length Length in seconds.
     1525         */
     1526        $length = apply_filters( 'recovery_mode_cookie_length', WEEK_IN_SECONDS );
     1527
     1528        if ( time() > $created_at + $length ) {
     1529                return new WP_Error( 'expired', __( 'Cookie expired.' ) );
     1530        }
     1531
     1532        $to_sign = sprintf( 'recovery_mode|%s|%s', $created_at, $random );
     1533        $hashed  = recovery_mode_hash( $to_sign );
     1534
     1535        if ( ! hash_equals( $signature, $hashed ) ) {
     1536                return new WP_Error( 'signature_mismatch', __( 'Invalid cookie.' ) );
     1537        }
     1538
     1539        return true;
     1540}
     1541
     1542/**
     1543 * Handle initializing Recovery Mode and sending a Recovery Mode link.
     1544 *
     1545 * @since 5.1.0
     1546 */
     1547function handle_recovery_mode_actions() {
     1548
     1549        if ( isset( $_COOKIE[ RECOVERY_MODE_COOKIE ] ) ) {
     1550                $validated = validate_recovery_mode_cookie();
     1551
     1552                if ( is_wp_error( $validated ) ) {
     1553                        clear_recovery_mode_cookie();
     1554
     1555                        wp_die( $validated, '', array(
     1556                                'link_url'  => get_recovery_mode_request_url(),
     1557                                'link_text' => __( 'Send a new email.' ),
     1558                        ) );
     1559                }
     1560
     1561                if ( ! defined( 'WP_RECOVERY_MODE' ) ) {
     1562                        define( 'WP_RECOVERY_MODE', true );
     1563                }
     1564
     1565                return;
     1566        }
     1567
     1568        if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
     1569                return;
     1570        }
     1571
     1572        if ( isset( $_GET['action'], $_GET['rm_key'] ) && 'begin_recovery_mode' === $_GET['action'] ) {
     1573                $validated = validate_recovery_mode_key( $_GET['rm_key'] );
     1574
     1575                if ( is_wp_error( $validated ) ) {
     1576                        wp_die( $validated, '', array(
     1577                                'link_url' => get_recovery_mode_request_url(),
     1578                                'link_text' => __( 'Send a new email.' ),
     1579                        ) );
     1580                }
     1581
     1582                set_recovery_mode_cookie();
     1583
     1584                // This should be loaded by set_recovery_mode_cookie() but load it again to be safe.
     1585                if ( ! function_exists( 'wp_redirect' ) ) {
     1586                        require_once ABSPATH . WPINC . '/pluggable.php';
     1587                }
     1588
     1589                $url     = add_query_arg( 'action', 'begun_recovery_mode', wp_login_url() );
     1590                $message = '<script type="application/javascript">window.location = \'' . esc_js( $url ) . '\';</script>';
     1591
     1592                wp_die( $message, '', array(
     1593                        'response'  => 200,
     1594                        'link_url'  => $url,
     1595                        'link_text' => __( 'Continue to Login' ),
     1596                ) );
     1597        }
     1598
     1599        if ( isset( $_GET['action'] ) && 'request_recovery_mode' === $_GET['action'] ) {
     1600                $sent = maybe_send_recovery_mode_email();
     1601
     1602                if ( ! function_exists( 'wp_redirect' ) ) {
     1603                        require_once ABSPATH . WPINC . '/pluggable.php';
     1604                }
     1605
     1606                if ( is_wp_error( $sent ) ) {
     1607                        $message = $sent;
     1608                        $args    = array();
     1609                } else {
     1610                        $message = __( 'Recovery Link sent to the Site Admin email address.' );
     1611                        $args    = array( 'response' => 200 );
     1612                }
     1613
     1614                wp_die( $message, '', $args );
     1615                exit;
     1616        }
     1617}
     1618
     1619/**
     1620 * Get a URL to request a recovery mode link be emailed to the user.
     1621 *
     1622 * @since 5.1.0
     1623 *
     1624 * @return string
     1625 */
     1626function get_recovery_mode_request_url() {
     1627        $url = add_query_arg( 'action', 'request_recovery_mode', wp_login_url() );
     1628
     1629        /**
     1630         * Filter the URL to request a recovery mode link be emailed to the user.
     1631         *
     1632         * @since 5.1.0
     1633         *
     1634         * @param string $url
     1635         */
     1636        return apply_filters( 'recovery_mode_request_url', $url );
     1637}
     1638
     1639/**
     1640 * Get a URL to begin recovery mode.
     1641 *
     1642 * @since 5.1.0
     1643 *
     1644 * @param string $key Recovery Mode key created by {@see generate_and_store_recovery_mode_key()}
     1645 *
     1646 * @return string
     1647 */
     1648function get_recovery_mode_begin_url( $key ) {
     1649
     1650        $url = add_query_arg( array(
     1651                'action' => 'begin_recovery_mode',
     1652                'rm_key' => $key,
     1653        ), wp_login_url() );
     1654
     1655        /**
     1656         * Filter the URL to begin recovery mode.
     1657         *
     1658         * @since 5.1.0
     1659         *
     1660         * @param string $url
     1661         * @param string $key
     1662         */
     1663        return apply_filters( 'recovery_mode_begin_url', $url, $key );
     1664}
     1665
     1666/**
     1667 * Send the recovery mode email if the rate limit has not been sent.
     1668 *
     1669 * @since 5.1.0
     1670 *
     1671 * @return true|WP_Error True if email sent, WP_Error otherwise.
     1672 */
     1673function maybe_send_recovery_mode_email() {
     1674
     1675        /**
     1676         * Filter the rate limit between sending new recovery mode email links.
     1677         *
     1678         * @since 5.1.0
     1679         *
     1680         * @param int $rate_limit Time to wait in seconds.
     1681         */
     1682        $rate_limit = apply_filters( 'recovery_mode_email_rate_limit', HOUR_IN_SECONDS );
     1683
     1684        $last_sent = get_site_option( 'recovery_mode_email_last_sent' );
     1685
     1686        if ( ! $last_sent || time() > $last_sent + $rate_limit ) {
     1687                $sent = send_recovery_mode_email();
     1688                update_site_option( 'recovery_mode_email_last_sent', time() );
     1689
     1690                if ( $sent ) {
     1691                        return true;
     1692                }
     1693
     1694                return new WP_Error( 'email_failed', __( 'The email could not be sent. Possible reason: your host may have disabled the mail() function.' ) );
     1695        }
     1696
     1697        $error = sprintf(
     1698                /* translators: 1. Last sent as a human time diff 2. Wait time as a human time diff. */
     1699                __( 'A recovery link was already sent %1$s ago. Please wait another %2$s before requesting a new email.' ),
     1700                human_time_diff( $last_sent ),
     1701                human_time_diff( $last_sent + $rate_limit )
     1702        );
     1703
     1704        return new WP_Error( 'email_sent_already', $error );
     1705}
     1706
     1707/**
     1708 * Send the Recovery Mode email to the site admin email address.
     1709 *
     1710 * @since 5.1.0
     1711 *
     1712 * @return bool Whether the email was sent successfully.
     1713 */
     1714function send_recovery_mode_email() {
     1715
     1716        $key      = generate_and_store_recovery_mode_key();
     1717        $url      = get_recovery_mode_begin_url( $key );
     1718        $blogname = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
     1719
     1720        $switched_locale = false;
     1721
     1722        // The switch_to_locale() function is loaded before it can actually be used.
     1723        if ( function_exists( 'switch_to_locale' ) && isset( $GLOBALS['wp_locale_switcher'] ) ) {
     1724                $switched_locale = switch_to_locale( get_locale() );
     1725        }
     1726
     1727        $message = __(
     1728                'Howdy,
     1729
     1730Your site recently experienced a fatal error. Click the link below to initiate recovery mode to fix the problem.
     1731
     1732This link expires in one hour.
     1733
     1734###LINK###'
     1735        );
     1736        $message = str_replace( '###LINK###', $url, $message );
     1737
     1738        $email = array(
     1739                'to'      => get_option( 'admin_email' ),
     1740                'subject' => __( '[%s] Recovery Mode' ),
     1741                'message' => $message,
     1742                'headers' => '',
     1743        );
     1744
     1745        /**
     1746         * Filter the contents of the Recovery Mode email.
     1747         *
     1748         * @since 5.1.0
     1749         *
     1750         * @param array  $email Used to build wp_mail().
     1751         * @param string $key   Recovery mode key.
     1752         */
     1753        $email = apply_filters( 'recovery_mode_email', $email, $key );
     1754
     1755        $sent = wp_mail(
     1756                $email['to'],
     1757                wp_specialchars_decode( sprintf( $email['subject'], $blogname ) ),
     1758                $email['message'],
     1759                $email['headers']
     1760        );
     1761
     1762        if ( $switched_locale ) {
     1763                restore_previous_locale();
     1764        }
     1765
     1766        return $sent;
     1767}
     1768
    12921769/**
    12931770 * Determines whether we are currently on an endpoint that should be protected against WSODs.
    12941771 *
  • src/wp-includes/ms-load.php

    diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php
    index 4f630cce27..91c9c8c301 100644
    a b function wp_get_active_network_plugins() { 
    5353                }
    5454        }
    5555
    56         /*
    57          * Remove plugins from the list of active plugins when we're on an endpoint
    58          * that should be protected against WSODs and the plugin is paused.
    59          */
    60         if ( is_protected_endpoint() ) {
    61                 $plugins = wp_skip_paused_plugins( $plugins );
    62         }
    63 
    6456        return $plugins;
    6557}
    6658
  • src/wp-includes/pluggable.php

    diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php
    index 0e9d4ad2f0..90b9dba6df 100644
    a b if ( ! function_exists( 'wp_clear_auth_cookie' ) ) : 
    979979
    980980                // Post password cookie
    981981                setcookie( 'wp-postpass_' . COOKIEHASH, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
     982
     983                clear_recovery_mode_cookie();
    982984        }
    983985endif;
    984986
  • src/wp-login.php

    diff --git a/src/wp-login.php b/src/wp-login.php
    index 1a302a35f1..4171e9065b 100644
    a b if ( isset( $_GET['key'] ) ) { 
    438438}
    439439
    440440// Validate action so as to default to the login screen.
    441 if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction' ), true ) && false === has_filter( 'login_form_' . $action ) ) {
     441if ( ! in_array( $action, array( 'postpass', 'logout', 'lostpassword', 'retrievepassword', 'resetpass', 'rp', 'register', 'login', 'confirmaction', 'begun_recovery_mode' ), true ) && false === has_filter( 'login_form_' . $action ) ) {
    442442        $action = 'login';
    443443}
    444444
    switch ( $action ) { 
    10241024                                $errors->add( 'registered', __( 'Registration complete. Please check your email.' ), 'message' );
    10251025                        } elseif ( strpos( $redirect_to, 'about.php?updated' ) ) {
    10261026                                $errors->add( 'updated', __( '<strong>You have successfully updated WordPress!</strong> Please log back in to see what&#8217;s new.' ), 'message' );
     1027                        } elseif ( 'begun_recovery_mode' === $action ) {
     1028                                $errors->add( 'begun_recovery_mode', __( 'Recovery Mode Initialized. Please login to continue.' ), 'message' );
    10271029                        }
    10281030                }
    10291031
  • src/wp-settings.php

    diff --git a/src/wp-settings.php b/src/wp-settings.php
    index e48208beb6..60f1e50485 100644
    a b wp_start_scraping_edited_file_errors(); 
    342342// Register the default theme directory root
    343343register_theme_directory( get_theme_root() );
    344344
     345// Handle users requesting a recovery mode link and initiating recovery mode.
     346handle_recovery_mode_actions();
     347
    345348// Load active plugins.
    346349foreach ( wp_get_active_and_valid_plugins() as $plugin ) {
    347350        wp_register_plugin_realpath( $plugin );
  • new file tests/phpunit/tests/recovery-mode.php

    diff --git a/tests/phpunit/tests/recovery-mode.php b/tests/phpunit/tests/recovery-mode.php
    new file mode 100644
    index 0000000000..a7d2a69781
    - +  
     1<?php
     2
     3class Tests_Recovery_Mode extends WP_UnitTestCase {
     4
     5        private static $subscriber;
     6        private static $administrator;
     7
     8        public static function setUpBeforeClass() {
     9                self::$subscriber    = self::factory()->user->create( array( 'role' => 'subscriber' ) );
     10                self::$administrator = self::factory()->user->create( array( 'role' => 'administrator' ) );
     11
     12                return parent::setUpBeforeClass();
     13        }
     14
     15        public static function tearDownAfterClass() {
     16                wp_delete_user( self::$subscriber );
     17                wp_delete_user( self::$administrator );
     18
     19                return parent::tearDownAfterClass();
     20        }
     21
     22        public function test_generate_and_store_returns_recovery_key() {
     23                $key = generate_and_store_recovery_mode_key();
     24
     25                $this->assertNotWPError( $key );
     26        }
     27
     28        public function test_verify_recovery_mode_key_returns_wp_error_if_no_key_set() {
     29                $error = validate_recovery_mode_key( 'abcd' );
     30
     31                $this->assertWPError( $error );
     32                $this->assertEquals( 'no_recovery_key_set', $error->get_error_code() );
     33        }
     34
     35        public function test_verify_recovery_mode_key_returns_wp_error_if_stored_format_is_invalid() {
     36                update_site_option( 'recovery_key', 'gibberish' );
     37                $error = validate_recovery_mode_key( 'abcd' );
     38
     39                $this->assertWPError( $error );
     40                $this->assertEquals( 'invalid_recovery_key_format', $error->get_error_code() );
     41        }
     42
     43        public function test_verify_recovery_mode_key_returns_wp_error_if_empty_key() {
     44                generate_and_store_recovery_mode_key();
     45                $error = validate_recovery_mode_key( '' );
     46
     47                $this->assertWPError( $error );
     48                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     49        }
     50
     51        public function test_verify_recovery_mode_key_returns_wp_error_if_hash_mismatch() {
     52                generate_and_store_recovery_mode_key();
     53                $error = validate_recovery_mode_key( 'abcd' );
     54
     55                $this->assertWPError( $error );
     56                $this->assertEquals( 'hash_mismatch', $error->get_error_code() );
     57        }
     58
     59        public function test_verify_recovery_mode_key_returns_wp_error_if_expired() {
     60                $key = generate_and_store_recovery_mode_key();
     61
     62                $record               = get_site_option( 'recovery_key' );
     63                $record['created_at'] = time() - HOUR_IN_SECONDS - 30;
     64                update_site_option( 'recovery_key', $record );
     65
     66                $error = validate_recovery_mode_key( $key );
     67
     68                $this->assertWPError( $error );
     69                $this->assertEquals( 'key_expired', $error->get_error_code() );
     70        }
     71
     72        public function test_verify_recovery_mode_key_returns_true_for_valid_key() {
     73
     74                $key = generate_and_store_recovery_mode_key();
     75                $this->assertTrue( validate_recovery_mode_key( $key ) );
     76        }
     77
     78        public function test_validate_recovery_mode_cookie_returns_wp_error_if_invalid_format() {
     79
     80                $error = validate_recovery_mode_cookie( 'gibbersih' );
     81                $this->assertWPError( $error );
     82                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     83
     84                $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format' ) );
     85                $this->assertWPError( $error );
     86                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     87
     88                $error = validate_recovery_mode_cookie( base64_encode( 'test|data|format|to|long' ) );
     89                $this->assertWPError( $error );
     90                $this->assertEquals( 'invalid_format', $error->get_error_code() );
     91        }
     92
     93        public function test_validate_recovery_mode_cookie_returns_wp_error_if_expired() {
     94
     95                $to_sign = sprintf( 'recovery_mode|%s|%s', time() - WEEK_IN_SECONDS - 30, wp_generate_password( 20, false ) );
     96                $signed  = recovery_mode_hash( $to_sign );
     97                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     98
     99                $error = validate_recovery_mode_cookie( $cookie );
     100                $this->assertWPError( $error );
     101                $this->assertEquals( 'expired', $error->get_error_code() );
     102        }
     103
     104        public function test_validate_recovery_mode_cookie_returns_wp_error_if_signature_mismatch() {
     105
     106                $cookie = generate_recovery_mode_cookie();
     107                $cookie .= 'gibbersih';
     108
     109                $error = validate_recovery_mode_cookie( $cookie );
     110                $this->assertWPError( $error );
     111                $this->assertEquals( 'signature_mismatch', $error->get_error_code() );
     112        }
     113
     114        public function test_validate_recovery_mode_cookie_returns_wp_error_if_created_at_is_invalid_format() {
     115
     116                $to_sign = sprintf( 'recovery_mode|%s|%s', 'month', wp_generate_password( 20, false ) );
     117                $signed  = recovery_mode_hash( $to_sign );
     118                $cookie  = base64_encode( sprintf( '%s|%s', $to_sign, $signed ) );
     119
     120                $error = validate_recovery_mode_cookie( $cookie );
     121                $this->assertWPError( $error );
     122                $this->assertEquals( 'invalid_created_at', $error->get_error_code() );
     123        }
     124
     125        public function test_generate_and_validate_recovery_mode_cookie_returns_true_for_valid_cookie() {
     126
     127                $cookie = generate_recovery_mode_cookie();
     128                $this->assertTrue( validate_recovery_mode_cookie( $cookie ) );
     129        }
     130}