From 61a39debefce4f90b86b83758e3dbf597f966d9d Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Tue, 11 Feb 2025 11:12:03 +0000 Subject: [PATCH] Security: Explicitly require the `hash` PHP extension and add requirement checks during installation and upgrade. This extension provides the `hash()` function and support for the SHA-256 algorithm, both of which are required for upcoming security related changes. This extension is almost universally enabled, however it is technically possible to disable it on PHP 7.2 and 7.3, hence the introduction of this requirement and the corresponding requirement checks prior to installing or upgrading WordPress. Props peterwilsoncc, ayeshrajans, dd32, SergeyBiryukov, johnbillion. Fixes #60638, #62815, #56017 See #21022 git-svn-id: https://develop.svn.wordpress.org/trunk@59803 602fd350-edb4-49c9-b593-d223f7449a82 --- composer.json | 1 + .../includes/class-wp-site-health.php | 2 +- src/wp-admin/includes/update-core.php | 39 +++--- src/wp-admin/install.php | 34 +++++- src/wp-admin/upgrade.php | 33 ++++-- src/wp-includes/class-wp-session-tokens.php | 7 +- src/wp-includes/class-wpdb.php | 4 +- src/wp-includes/compat.php | 112 ------------------ src/wp-includes/load.php | 31 ++++- src/wp-includes/pluggable.php | 8 +- src/wp-includes/version.php | 10 ++ src/wp-settings.php | 15 +-- 12 files changed, 131 insertions(+), 165 deletions(-) diff --git a/composer.json b/composer.json index aea6ea7a42..0a8a9c7809 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "issues": "https://core.trac.wordpress.org/" }, "require": { + "ext-hash": "*", "ext-json": "*", "php": ">=7.2.24" }, diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 3f89fc0a38..19d109d447 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -923,7 +923,7 @@ class WP_Site_Health { ), 'hash' => array( 'function' => 'hash', - 'required' => false, + 'required' => true, ), 'imagick' => array( 'extension' => 'imagick', diff --git a/src/wp-admin/includes/update-core.php b/src/wp-admin/includes/update-core.php index a29f389860..941e5e1b25 100644 --- a/src/wp-admin/includes/update-core.php +++ b/src/wp-admin/includes/update-core.php @@ -1009,9 +1009,6 @@ $_new_bundled_files = array( * @global array $_old_requests_files * @global array $_new_bundled_files * @global wpdb $wpdb WordPress database abstraction object. - * @global string $wp_version - * @global string $required_php_version - * @global string $required_mysql_version * * @param string $from New release unzipped path. * @param string $to Path to old WordPress installation. @@ -1075,7 +1072,7 @@ function update_core( $from, $to ) { } /* - * Import $wp_version, $required_php_version, and $required_mysql_version from the new version. + * Import $wp_version, $required_php_version, $required_php_extensions, and $required_mysql_version from the new version. * DO NOT globalize any variables imported from `version-current.php` in this function. * * BC Note: $wp_filesystem->wp_content_dir() returned unslashed pre-2.8. @@ -1181,17 +1178,29 @@ function update_core( $from, $to ) { ); } - // Add a warning when the JSON PHP extension is missing. - if ( ! extension_loaded( 'json' ) ) { - return new WP_Error( - 'php_not_compatible_json', - sprintf( - /* translators: 1: WordPress version number, 2: The PHP extension name needed. */ - __( 'The update cannot be installed because WordPress %1$s requires the %2$s PHP extension.' ), - $wp_version, - 'JSON' - ) - ); + if ( isset( $required_php_extensions ) && is_array( $required_php_extensions ) ) { + $missing_extensions = new WP_Error(); + + foreach ( $required_php_extensions as $extension ) { + if ( extension_loaded( $extension ) ) { + continue; + } + + $missing_extensions->add( + "php_not_compatible_{$extension}", + sprintf( + /* translators: 1: WordPress version number, 2: The PHP extension name needed. */ + __( 'The update cannot be installed because WordPress %1$s requires the %2$s PHP extension.' ), + $wp_version, + $extension + ) + ); + } + + // Add a warning when required PHP extensions are missing. + if ( $missing_extensions->has_errors() ) { + return $missing_extensions; + } } /** This filter is documented in wp-admin/includes/update-core.php */ diff --git a/src/wp-admin/install.php b/src/wp-admin/install.php index e81331acfc..736c5138e9 100644 --- a/src/wp-admin/install.php +++ b/src/wp-admin/install.php @@ -232,12 +232,13 @@ if ( is_blog_installed() ) { } /** - * @global string $wp_version The WordPress version string. - * @global string $required_php_version The required PHP version string. - * @global string $required_mysql_version The required MySQL version string. - * @global wpdb $wpdb WordPress database abstraction object. + * @global string $wp_version The WordPress version string. + * @global string $required_php_version The required PHP version string. + * @global string[] $required_php_extensions The names of required PHP extensions. + * @global string $required_mysql_version The required MySQL version string. + * @global wpdb $wpdb WordPress database abstraction object. */ -global $wp_version, $required_php_version, $required_mysql_version, $wpdb; +global $wp_version, $required_php_version, $required_php_extensions, $required_mysql_version, $wpdb; $php_version = PHP_VERSION; $mysql_version = $wpdb->db_version(); @@ -298,6 +299,29 @@ if ( ! $mysql_compat || ! $php_compat ) { die( '

' . __( 'Requirements Not Met' ) . '

' . $compat . '

' ); } +if ( isset( $required_php_extensions ) && is_array( $required_php_extensions ) ) { + $missing_extensions = array(); + + foreach ( $required_php_extensions as $extension ) { + if ( extension_loaded( $extension ) ) { + continue; + } + + $missing_extensions[] = sprintf( + /* translators: 1: URL to WordPress release notes, 2: WordPress version number, 3: The PHP extension name needed. */ + __( 'You cannot install because WordPress %2$s requires the %3$s PHP extension.' ), + $version_url, + $wp_version, + $extension + ); + } + + if ( count( $missing_extensions ) > 0 ) { + display_header(); + die( '

' . __( 'Requirements Not Met' ) . '

' . implode( '

', $missing_extensions ) . '

' ); + } +} + if ( ! is_string( $wpdb->base_prefix ) || '' === $wpdb->base_prefix ) { display_header(); die( diff --git a/src/wp-admin/upgrade.php b/src/wp-admin/upgrade.php index 9edadd9be2..ea98082704 100644 --- a/src/wp-admin/upgrade.php +++ b/src/wp-admin/upgrade.php @@ -36,12 +36,13 @@ if ( 'upgrade_db' === $step ) { } /** - * @global string $wp_version The WordPress version string. - * @global string $required_php_version The required PHP version string. - * @global string $required_mysql_version The required MySQL version string. - * @global wpdb $wpdb WordPress database abstraction object. + * @global string $wp_version The WordPress version string. + * @global string $required_php_version The required PHP version string. + * @global string[] $required_php_extensions The names of required PHP extensions. + * @global string $required_mysql_version The required MySQL version string. + * @global wpdb $wpdb WordPress database abstraction object. */ -global $wp_version, $required_php_version, $required_mysql_version, $wpdb; +global $wp_version, $required_php_version, $required_php_extensions, $required_mysql_version, $wpdb; $step = (int) $step; @@ -54,6 +55,24 @@ if ( file_exists( WP_CONTENT_DIR . '/db.php' ) && empty( $wpdb->is_mysql ) ) { $mysql_compat = version_compare( $mysql_version, $required_mysql_version, '>=' ); } +$missing_extensions = array(); + +if ( isset( $required_php_extensions ) && is_array( $required_php_extensions ) ) { + foreach ( $required_php_extensions as $extension ) { + if ( extension_loaded( $extension ) ) { + continue; + } + + $missing_extensions[] = sprintf( + /* translators: 1: URL to WordPress release notes, 2: WordPress version number, 3: The PHP extension name needed. */ + __( 'You cannot upgrade because WordPress %2$s requires the %3$s PHP extension.' ), + $version_url, + $wp_version, + $extension + ); + } +} + header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); ?> @@ -126,8 +145,8 @@ elseif ( ! $php_compat || ! $mysql_compat ) : } echo '

' . $message . '

'; - ?> - 0 ) : + echo '

' . implode( '

', $missing_extensions ) . '

'; else : switch ( $step ) : case 0: diff --git a/src/wp-includes/class-wp-session-tokens.php b/src/wp-includes/class-wp-session-tokens.php index feabf698b7..9482e1b948 100644 --- a/src/wp-includes/class-wp-session-tokens.php +++ b/src/wp-includes/class-wp-session-tokens.php @@ -68,12 +68,7 @@ abstract class WP_Session_Tokens { * @return string A hash of the session token (a verifier). */ private function hash_token( $token ) { - // If ext/hash is not present, use sha1() instead. - if ( function_exists( 'hash' ) ) { - return hash( 'sha256', $token ); - } else { - return sha1( $token ); - } + return hash( 'sha256', $token ); } /** diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 5ace90d490..c6e6099c26 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -2412,12 +2412,10 @@ class wpdb { static $placeholder; if ( ! $placeholder ) { - // If ext/hash is not present, compat.php's hash_hmac() does not support sha256. - $algo = function_exists( 'hash' ) ? 'sha256' : 'sha1'; // Old WP installs may not have AUTH_SALT defined. $salt = defined( 'AUTH_SALT' ) && AUTH_SALT ? AUTH_SALT : (string) rand(); - $placeholder = '{' . hash_hmac( $algo, uniqid( $salt, true ), $salt ) . '}'; + $placeholder = '{' . hash_hmac( 'sha256', uniqid( $salt, true ), $salt ) . '}'; } /* diff --git a/src/wp-includes/compat.php b/src/wp-includes/compat.php index 01b9996c75..fc6db436fe 100644 --- a/src/wp-includes/compat.php +++ b/src/wp-includes/compat.php @@ -263,118 +263,6 @@ function _mb_strlen( $str, $encoding = null ) { return --$count; } -if ( ! function_exists( 'hash_hmac' ) ) : - /** - * Compat function to mimic hash_hmac(). - * - * The Hash extension is bundled with PHP by default since PHP 5.1.2. - * However, the extension may be explicitly disabled on select servers. - * As of PHP 7.4.0, the Hash extension is a core PHP extension and can no - * longer be disabled. - * I.e. when PHP 7.4.0 becomes the minimum requirement, this polyfill - * and the associated `_hash_hmac()` function can be safely removed. - * - * @ignore - * @since 3.2.0 - * - * @see _hash_hmac() - * - * @param string $algo Hash algorithm. Accepts 'md5' or 'sha1'. - * @param string $data Data to be hashed. - * @param string $key Secret key to use for generating the hash. - * @param bool $binary Optional. Whether to output raw binary data (true), - * or lowercase hexits (false). Default false. - * @return string|false The hash in output determined by `$binary`. - * False if `$algo` is unknown or invalid. - */ - function hash_hmac( $algo, $data, $key, $binary = false ) { - return _hash_hmac( $algo, $data, $key, $binary ); - } -endif; - -/** - * Internal compat function to mimic hash_hmac(). - * - * @ignore - * @since 3.2.0 - * - * @param string $algo Hash algorithm. Accepts 'md5' or 'sha1'. - * @param string $data Data to be hashed. - * @param string $key Secret key to use for generating the hash. - * @param bool $binary Optional. Whether to output raw binary data (true), - * or lowercase hexits (false). Default false. - * @return string|false The hash in output determined by `$binary`. - * False if `$algo` is unknown or invalid. - */ -function _hash_hmac( $algo, $data, $key, $binary = false ) { - $packs = array( - 'md5' => 'H32', - 'sha1' => 'H40', - ); - - if ( ! isset( $packs[ $algo ] ) ) { - return false; - } - - $pack = $packs[ $algo ]; - - if ( strlen( $key ) > 64 ) { - $key = pack( $pack, $algo( $key ) ); - } - - $key = str_pad( $key, 64, chr( 0 ) ); - - $ipad = ( substr( $key, 0, 64 ) ^ str_repeat( chr( 0x36 ), 64 ) ); - $opad = ( substr( $key, 0, 64 ) ^ str_repeat( chr( 0x5C ), 64 ) ); - - $hmac = $algo( $opad . pack( $pack, $algo( $ipad . $data ) ) ); - - if ( $binary ) { - return pack( $pack, $hmac ); - } - - return $hmac; -} - -if ( ! function_exists( 'hash_equals' ) ) : - /** - * Timing attack safe string comparison. - * - * Compares two strings using the same time whether they're equal or not. - * - * Note: It can leak the length of a string when arguments of differing length are supplied. - * - * This function was added in PHP 5.6. - * However, the Hash extension may be explicitly disabled on select servers. - * As of PHP 7.4.0, the Hash extension is a core PHP extension and can no - * longer be disabled. - * I.e. when PHP 7.4.0 becomes the minimum requirement, this polyfill - * can be safely removed. - * - * @since 3.9.2 - * - * @param string $known_string Expected string. - * @param string $user_string Actual, user supplied, string. - * @return bool Whether strings are equal. - */ - function hash_equals( $known_string, $user_string ) { - $known_string_length = strlen( $known_string ); - - if ( strlen( $user_string ) !== $known_string_length ) { - return false; - } - - $result = 0; - - // Do not attempt to "optimize" this. - for ( $i = 0; $i < $known_string_length; $i++ ) { - $result |= ord( $known_string[ $i ] ) ^ ord( $user_string[ $i ] ); - } - - return 0 === $result; - } -endif; - // sodium_crypto_box() was introduced in PHP 7.2. if ( ! function_exists( 'sodium_crypto_box' ) ) { require ABSPATH . WPINC . '/sodium_compat/autoload.php'; diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 5066cd16ee..0526cb175d 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -147,11 +147,12 @@ function wp_populate_basic_auth_from_authorization_header() { * @since 3.0.0 * @access private * - * @global string $required_php_version The required PHP version string. - * @global string $wp_version The WordPress version string. + * @global string $required_php_version The required PHP version string. + * @global string[] $required_php_extensions The names of required PHP extensions. + * @global string $wp_version The WordPress version string. */ function wp_check_php_mysql_versions() { - global $required_php_version, $wp_version; + global $required_php_version, $required_php_extensions, $wp_version; $php_version = PHP_VERSION; @@ -168,6 +169,30 @@ function wp_check_php_mysql_versions() { exit( 1 ); } + $missing_extensions = array(); + + if ( isset( $required_php_extensions ) && is_array( $required_php_extensions ) ) { + foreach ( $required_php_extensions as $extension ) { + if ( extension_loaded( $extension ) ) { + continue; + } + + $missing_extensions[] = sprintf( + 'WordPress %1$s requires the %2$s PHP extension.', + $wp_version, + $extension + ); + } + } + + if ( count( $missing_extensions ) > 0 ) { + $protocol = wp_get_server_protocol(); + header( sprintf( '%s 500 Internal Server Error', $protocol ), true, 500 ); + header( 'Content-Type: text/html; charset=utf-8' ); + echo implode( '
', $missing_extensions ); + exit( 1 ); + } + // This runs before default constants are defined, so we can't assume WP_CONTENT_DIR is set yet. $wp_content_dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content'; diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 60ad0841ed..bc61ba0bdf 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -772,9 +772,7 @@ if ( ! function_exists( 'wp_validate_auth_cookie' ) ) : $key = wp_hash( $username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ); - // If ext/hash is not present, compat.php's hash_hmac() does not support sha256. - $algo = function_exists( 'hash' ) ? 'sha256' : 'sha1'; - $hash = hash_hmac( $algo, $username . '|' . $expiration . '|' . $token, $key ); + $hash = hash_hmac( 'sha256', $username . '|' . $expiration . '|' . $token, $key ); if ( ! hash_equals( $hash, $hmac ) ) { /** @@ -875,9 +873,7 @@ if ( ! function_exists( 'wp_generate_auth_cookie' ) ) : $key = wp_hash( $user->user_login . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ); - // If ext/hash is not present, compat.php's hash_hmac() does not support sha256. - $algo = function_exists( 'hash' ) ? 'sha256' : 'sha1'; - $hash = hash_hmac( $algo, $user->user_login . '|' . $expiration . '|' . $token, $key ); + $hash = hash_hmac( 'sha256', $user->user_login . '|' . $expiration . '|' . $token, $key ); $cookie = $user->user_login . '|' . $expiration . '|' . $token . '|' . $hash; diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index ce3923701f..7bdeef2e7d 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -39,6 +39,16 @@ $tinymce_version = '49110-20201110'; */ $required_php_version = '7.2.24'; +/** + * Holds the names of required PHP extensions. + * + * @global string[] $required_php_extensions + */ +$required_php_extensions = array( + 'json', + 'hash', +); + /** * Holds the required MySQL version. * diff --git a/src/wp-settings.php b/src/wp-settings.php index ec08ff1149..05a13f21a7 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -22,14 +22,15 @@ define( 'WPINC', 'wp-includes' ); * include version.php from another installation and don't override * these values if already set. * - * @global string $wp_version The WordPress version string. - * @global int $wp_db_version WordPress database version. - * @global string $tinymce_version TinyMCE version. - * @global string $required_php_version The required PHP version string. - * @global string $required_mysql_version The required MySQL version string. - * @global string $wp_local_package Locale code of the package. + * @global string $wp_version The WordPress version string. + * @global int $wp_db_version WordPress database version. + * @global string $tinymce_version TinyMCE version. + * @global string $required_php_version The required PHP version string. + * @global string[] $required_php_extensions The names of required PHP extensions. + * @global string $required_mysql_version The required MySQL version string. + * @global string $wp_local_package Locale code of the package. */ -global $wp_version, $wp_db_version, $tinymce_version, $required_php_version, $required_mysql_version, $wp_local_package; +global $wp_version, $wp_db_version, $tinymce_version, $required_php_version, $required_php_extensions, $required_mysql_version, $wp_local_package; require ABSPATH . WPINC . '/version.php'; require ABSPATH . WPINC . '/compat.php'; require ABSPATH . WPINC . '/load.php';