Skip to content

Commit

Permalink
Security: Explicitly require the hash PHP extension and add require…
Browse files Browse the repository at this point in the history
…ment 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
  • Loading branch information
johnbillion committed Feb 11, 2025
1 parent d71f29f commit 61a39de
Show file tree
Hide file tree
Showing 12 changed files with 131 additions and 165 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"issues": "https://core.trac.wordpress.org/"
},
"require": {
"ext-hash": "*",
"ext-json": "*",
"php": ">=7.2.24"
},
Expand Down
2 changes: 1 addition & 1 deletion src/wp-admin/includes/class-wp-site-health.php
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,7 @@ public function get_test_php_extensions() {
),
'hash' => array(
'function' => 'hash',
'required' => false,
'required' => true,
),
'imagick' => array(
'extension' => 'imagick',
Expand Down
39 changes: 24 additions & 15 deletions src/wp-admin/includes/update-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -1009,9 +1009,6 @@
* @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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 */
Expand Down
34 changes: 29 additions & 5 deletions src/wp-admin/install.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,13 @@ function display_setup_form( $error = null ) {
}

/**
* @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();
Expand Down Expand Up @@ -298,6 +299,29 @@ function display_setup_form( $error = null ) {
die( '<h1>' . __( 'Requirements Not Met' ) . '</h1><p>' . $compat . '</p></body></html>' );
}

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 <a href="%1$s">WordPress %2$s</a> requires the %3$s PHP extension.' ),
$version_url,
$wp_version,
$extension
);
}

if ( count( $missing_extensions ) > 0 ) {
display_header();
die( '<h1>' . __( 'Requirements Not Met' ) . '</h1><p>' . implode( '</p><p>', $missing_extensions ) . '</p></body></html>' );
}
}

if ( ! is_string( $wpdb->base_prefix ) || '' === $wpdb->base_prefix ) {
display_header();
die(
Expand Down
33 changes: 26 additions & 7 deletions src/wp-admin/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@
}

/**
* @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;

Expand All @@ -54,6 +55,24 @@
$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 <a href="%1$s">WordPress %2$s</a> requires the %3$s PHP extension.' ),
$version_url,
$wp_version,
$extension
);
}
}

header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) );
?>
<!DOCTYPE html>
Expand Down Expand Up @@ -126,8 +145,8 @@
}

echo '<p>' . $message . '</p>';
?>
<?php
elseif ( count( $missing_extensions ) > 0 ) :
echo '<p>' . implode( '</p><p>', $missing_extensions ) . '</p>';
else :
switch ( $step ) :
case 0:
Expand Down
7 changes: 1 addition & 6 deletions src/wp-includes/class-wp-session-tokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,7 @@ final public static function get_instance( $user_id ) {
* @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 );
}

/**
Expand Down
4 changes: 1 addition & 3 deletions src/wp-includes/class-wpdb.php
Original file line number Diff line number Diff line change
Expand Up @@ -2412,12 +2412,10 @@ public function placeholder_escape() {
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 ) . '}';
}

/*
Expand Down
112 changes: 0 additions & 112 deletions src/wp-includes/compat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
31 changes: 28 additions & 3 deletions src/wp-includes/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 <code>%2$s</code> 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( '<br>', $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';

Expand Down
8 changes: 2 additions & 6 deletions src/wp-includes/pluggable.php
Original file line number Diff line number Diff line change
Expand Up @@ -772,9 +772,7 @@ function wp_validate_auth_cookie( $cookie = '', $scheme = '' ) {

$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 ) ) {
/**
Expand Down Expand Up @@ -875,9 +873,7 @@ function wp_generate_auth_cookie( $user_id, $expiration, $scheme = 'auth', $toke

$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;

Expand Down
Loading

0 comments on commit 61a39de

Please sign in to comment.