Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Next Next commit
Reintroduce support for passwords hashed with MD5.
  • Loading branch information
johnbillion committed Feb 26, 2025
commit c005d739def0908a5c2fbdcc7c500a4a4a5e3d1b
39 changes: 17 additions & 22 deletions src/wp-includes/pluggable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2724,7 +2724,6 @@ function wp_hash_password(
* @since 2.5.0
* @since 6.8.0 Passwords in WordPress are now hashed with bcrypt by default. A
* password that wasn't hashed with bcrypt will be checked with phpass.
* Passwords hashed with md5 are no longer supported.
*
* @global PasswordHash $wp_hasher phpass object. Used as a fallback for verifying
* passwords that were hashed with phpass.
Expand All @@ -2742,30 +2741,14 @@ function wp_check_password(
) {
global $wp_hasher;

$check = false;

// If the hash is still md5 or otherwise truncated then invalidate it.
if ( strlen( $hash ) <= 32 ) {
/**
* Filters whether the plaintext password matches the hashed password.
*
* @since 2.5.0
* @since 6.8.0 Passwords are now hashed with bcrypt by default.
* Old passwords may still be hashed with phpass.
*
* @param bool $check Whether the passwords match.
* @param string $password The plaintext password.
* @param string $hash The hashed password.
* @param string|int $user_id Optional ID of a user associated with the password.
* Can be empty.
*/
return apply_filters( 'check_password', $check, $password, $hash, $user_id );
}

if ( ! empty( $wp_hasher ) ) {
// Check the hash using md5 regardless of the current hashing mechanism.
$check = hash_equals( $hash, md5( $password ) );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since hash_equals requires a string as the first argument, would it make sense to add a check or force it to be a string here?

} elseif ( ! empty( $wp_hasher ) ) {
// Check the password using the overridden hasher.
$check = $wp_hasher->CheckPassword( $password, $hash );
} elseif ( strlen( $password ) > 4096 ) {
// Passwords longer than 4096 characters are not supported.
$check = false;
} elseif ( str_starts_with( $hash, '$wp' ) ) {
// Check the password using the current prefixed hash.
Expand All @@ -2780,7 +2763,19 @@ function wp_check_password(
$check = password_verify( $password, $hash );
}

/** This filter is documented in wp-includes/pluggable.php */
/**
* Filters whether the plaintext password matches the hashed password.
*
* @since 2.5.0
* @since 6.8.0 Passwords are now hashed with bcrypt by default.
* Old passwords may still be hashed with phpass or md5.
*
* @param bool $check Whether the passwords match.
* @param string $password The plaintext password.
* @param string $hash The hashed password.
* @param string|int $user_id Optional ID of a user associated with the password.
* Can be empty.
*/
return apply_filters( 'check_password', $check, $password, $hash, $user_id );
}
endif;
Expand Down
74 changes: 70 additions & 4 deletions tests/phpunit/tests/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -318,10 +318,10 @@ public function test_wp_check_password_supports_argon2id_hash() {
/**
* @ticket 21022
*/
public function test_wp_check_password_does_not_support_md5_hashes() {
public function test_wp_check_password_supports_md5_hash() {
$password = 'password';
$hash = md5( $password );
$this->assertFalse( wp_check_password( $password, $hash ) );
$this->assertTrue( wp_check_password( $password, $hash ) );
$this->assertSame( 1, did_filter( 'check_password' ) );
}

Expand Down Expand Up @@ -363,8 +363,6 @@ public function test_wp_check_password_does_not_support_empty_password( $value )

public function data_empty_values() {
return array(
// Integer zero:
array( 0 ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we force $hash in wp_check_password to be a string, this can be restored.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, although I'm not sure why I added that test value in the first place (it was only introduced in r59828). There is no code path in core that will result in something other than a string being passed to either the $password or $hash parameters of wp_check_password(), and both are documented as strings.

My preference would be to not add a string check or string coercion to the function, and allow PHP to continue triggering the appropriate warning depending on what is passed. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No objections from me

// String zero:
array( '0' ),
// Zero-length string:
Expand Down Expand Up @@ -1079,6 +1077,42 @@ public function test_phpass_password_is_rehashed_after_successful_user_password_
$this->assertSame( self::$user_id, $user->ID );
}

/**
* @dataProvider data_usernames
*
* @ticket 21022
*/
public function test_md5_password_is_rehashed_after_successful_user_password_authentication( $username_or_email ) {
$password = 'password';

// Set the user password with the old md5 algorithm.
self::set_user_password_with_md5( $password, self::$user_id );

// Verify that the password needs rehashing.
$hash = get_userdata( self::$user_id )->user_pass;
$this->assertTrue( wp_password_needs_rehash( $hash, self::$user_id ) );

// Authenticate.
$user = wp_authenticate( $username_or_email, $password );

// Verify that the md5 password hash was valid.
$this->assertNotWPError( $user );
$this->assertInstanceOf( 'WP_User', $user );
$this->assertSame( self::$user_id, $user->ID );

// Verify that the password no longer needs rehashing.
$hash = get_userdata( self::$user_id )->user_pass;
$this->assertFalse( wp_password_needs_rehash( $hash, self::$user_id ) );

// Authenticate a second time to ensure the new hash is valid.
$user = wp_authenticate( $username_or_email, $password );

// Verify that the bcrypt password hash is valid.
$this->assertNotWPError( $user );
$this->assertInstanceOf( 'WP_User', $user );
$this->assertSame( self::$user_id, $user->ID );
}

/**
* @dataProvider data_usernames
*
Expand Down Expand Up @@ -1772,6 +1806,38 @@ private static function set_user_password_with_phpass( string $password, int $us
clean_user_cache( $user_id );
}

/**
* Test the tests
*
* @covers Tests_Auth::set_user_password_with_md5
*
* @ticket 21022
*/
public function test_set_user_password_with_md5() {
$password = 'password';

// Set the user password with the old md5 algorithm.
self::set_user_password_with_md5( $password, self::$user_id );

// Ensure the password is hashed with md5.
$hash = get_userdata( self::$user_id )->user_pass;
$this->assertSame( md5( $password ), $hash );
}

private static function set_user_password_with_md5( string $password, int $user_id ) {
global $wpdb;

$wpdb->update(
$wpdb->users,
array(
'user_pass' => md5( $password ),
),
array(
'ID' => $user_id,
)
);
clean_user_cache( $user_id );
}

/**
* Test the tests
Expand Down
Loading