diff --git a/src/wp-admin/user-new.php b/src/wp-admin/user-new.php index ba027b06bb366..88b0d85221c31 100644 --- a/src/wp-admin/user-new.php +++ b/src/wp-admin/user-new.php @@ -230,6 +230,13 @@ wp_ensure_editable_role( $_REQUEST['role'] ); + // Capture the activation payload from the action so we can activate immediately if noconfirmation is set. + $activation_payload = null; + $capture_payload = static function ( $u, $e, $payload ) use ( &$activation_payload ) { + $activation_payload = $payload; + }; + add_action( 'after_signup_user', $capture_payload, 10, 3 ); + wpmu_signup_user( $new_user_login, $new_user_email, @@ -239,9 +246,10 @@ ) ); + remove_action( 'after_signup_user', $capture_payload, 10 ); + if ( isset( $_POST['noconfirmation'] ) && current_user_can( 'manage_network_users' ) ) { - $key = $wpdb->get_var( $wpdb->prepare( "SELECT activation_key FROM {$wpdb->signups} WHERE user_login = %s AND user_email = %s", $new_user_login, $new_user_email ) ); - $new_user = wpmu_activate_signup( $key ); + $new_user = wpmu_activate_signup( $activation_payload ); if ( is_wp_error( $new_user ) ) { $redirect = add_query_arg( array( 'update' => 'addnoconfirmation' ), 'user-new.php' ); } elseif ( ! is_user_member_of_blog( $new_user['user_id'] ) ) { diff --git a/src/wp-includes/ms-functions.php b/src/wp-includes/ms-functions.php index f1cbc62fa8ec7..67d8c385f7224 100644 --- a/src/wp-includes/ms-functions.php +++ b/src/wp-includes/ms-functions.php @@ -802,7 +802,11 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = array() ) { global $wpdb; - $key = substr( md5( time() . wp_rand() . $domain ), 0, 16 ); + $key = substr( md5( time() . wp_rand() . $domain ), 0, 16 ); + $timestamp = time(); + $triplet = $user_email . ':' . $timestamp . ':' . $key; + $payload = rtrim( strtr( base64_encode( $triplet ), '+/', '-_' ), '=' ); + $hash = base64_encode( hash_hmac( 'sha256', $triplet, wp_salt( 'auth' ), true ) ); /** * Filters the metadata for a site signup. @@ -830,7 +834,7 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a 'user_login' => $user, 'user_email' => $user_email, 'registered' => current_time( 'mysql', true ), - 'activation_key' => $key, + 'activation_key' => $hash, 'meta' => serialize( $meta ), ) ); @@ -845,10 +849,10 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @param string $title The requested site title. * @param string $user The user's requested login name. * @param string $user_email The user's email address. - * @param string $key The user's activation key. + * @param string $payload The URL-safe base64-encoded activation payload sent via email. * @param array $meta Signup meta data. By default, contains the requested privacy setting and lang_id. */ - do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta ); + do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $payload, $meta ); } /** @@ -872,6 +876,10 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { $user = preg_replace( '/\s+/', '', sanitize_user( $user, true ) ); $user_email = sanitize_email( $user_email ); $key = substr( md5( time() . wp_rand() . $user_email ), 0, 16 ); + $timestamp = time(); + $triplet = $user_email . ':' . $timestamp . ':' . $key; + $payload = rtrim( strtr( base64_encode( $triplet ), '+/', '-_' ), '=' ); + $hash = base64_encode( hash_hmac( 'sha256', $triplet, wp_salt( 'auth' ), true ) ); /** * Filters the metadata for a user signup. @@ -896,7 +904,7 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { 'user_login' => $user, 'user_email' => $user_email, 'registered' => current_time( 'mysql', true ), - 'activation_key' => $key, + 'activation_key' => $hash, 'meta' => serialize( $meta ), ) ); @@ -908,10 +916,10 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { * * @param string $user The user's requested login name. * @param string $user_email The user's email address. - * @param string $key The user's activation key. + * @param string $payload The URL-safe base64-encoded activation payload sent via email. * @param array $meta Signup meta data. Default empty array. */ - do_action( 'after_signup_user', $user, $user_email, $key, $meta ); + do_action( 'after_signup_user', $user, $user_email, $payload, $meta ); } /** @@ -1197,10 +1205,55 @@ function wpmu_activate_signup( ) { global $wpdb; - $signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $key ) ); + /* + * New keys are URL-safe base64-encoded triplets: email:timestamp:random. + * The DB stores hash_hmac('sha256', triplet, AUTH_KEY+AUTH_SALT). + * Legacy keys are plain 16-char hex strings stored directly in the DB. + */ + // Restore base64 padding before decoding (it was stripped when the payload was generated). + $padded = $key . str_repeat( '=', ( 4 - strlen( $key ) % 4 ) % 4 ); + $decoded = base64_decode( strtr( $padded, '-_', '+/' ), true ); + $parts = is_string( $decoded ) ? explode( ':', $decoded, 3 ) : array(); + + if ( 3 === count( $parts ) && is_email( $parts[0] ) && ctype_digit( $parts[1] ) ) { + // New HMAC format. + list( $email, $timestamp, $random ) = $parts; + + $expected_hash = base64_encode( hash_hmac( 'sha256', $decoded, wp_salt( 'auth' ), true ) ); + + $signup = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM $wpdb->signups WHERE user_email = %s AND activation_key = %s", + $email, + $expected_hash + ) + ); + + if ( empty( $signup ) ) { + return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); + } + + /** + * Filters the expiration time of signup activation keys. + * + * @since 6.9.0 + * + * @param int $expiration_duration The expiration time in seconds. + */ + $expiration_duration = apply_filters( 'activate_signup_expiration', DAY_IN_SECONDS ); - if ( empty( $signup ) ) { - return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); + if ( time() > ( (int) $timestamp + $expiration_duration ) ) { + return new WP_Error( 'expired_key', __( 'Invalid key' ) ); + } + } else { + // Legacy plain-text key — allow for backwards compatibility with pre-upgrade pending activations. + $signup = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $key ) + ); + + if ( empty( $signup ) ) { + return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); + } } if ( $signup->active ) { @@ -1235,7 +1288,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'activation_key' => $signup->activation_key ) ); if ( isset( $user_already_exists ) ) { @@ -1277,7 +1330,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'activation_key' => $signup->activation_key ) ); } return $blog_id; @@ -1289,7 +1342,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'signup_id' => $signup->signup_id ) ); /** diff --git a/tests/phpunit/tests/multisite/wpmuActivateSignup.php b/tests/phpunit/tests/multisite/wpmuActivateSignup.php new file mode 100644 index 0000000000000..d61ed97cf1f10 --- /dev/null +++ b/tests/phpunit/tests/multisite/wpmuActivateSignup.php @@ -0,0 +1,190 @@ + null ); + $listener = static function ( $u, $e, $payload ) use ( &$data ) { + $data['key'] = $payload; + }; + add_filter( 'wpmu_signup_user_notification', '__return_false' ); + add_action( 'after_signup_user', $listener, 10, 3 ); + wpmu_signup_user( $login, $email ); + remove_action( 'after_signup_user', $listener, 10 ); + remove_filter( 'wpmu_signup_user_notification', '__return_false' ); + return $data; + } + + /** + * @ticket 38474 + */ + public function test_signup_user_stores_hmac_hash() { + global $wpdb; + + $this->signup_user( 'tuser38474a', 'tuser38474a@example.com' ); + + $stored = $wpdb->get_var( "SELECT activation_key FROM $wpdb->signups WHERE user_login = 'tuser38474a'" ); + + $this->assertMatchesRegularExpression( '/^[A-Za-z0-9+\/]{43}=$/', $stored, 'Stored activation key must be a 44-char base64-encoded SHA-256 HMAC.' ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_succeeds_with_valid_payload() { + add_filter( 'wpmu_welcome_user_notification', '__return_false' ); + + $data = $this->signup_user( 'tuser38474b', 'tuser38474b@example.com' ); + $result = wpmu_activate_signup( $data['key'] ); + + remove_filter( 'wpmu_welcome_user_notification', '__return_false' ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'user_id', $result ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_fails_with_wrong_key() { + $this->signup_user( 'tuser38474c', 'tuser38474c@example.com' ); + $result = wpmu_activate_signup( 'thisisnottherightkey' ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_key', $result->get_error_code() ); + } + + /** + * Legacy plain-text keys (stored before the hashing upgrade) must still activate + * successfully so that users with pending activation emails are not broken by the upgrade. + * + * @ticket 38474 + */ + public function test_activate_signup_allows_legacy_plain_text_key() { + global $wpdb; + + add_filter( 'wpmu_welcome_user_notification', '__return_false' ); + + $plain_key = 'abc123legacykey38474'; + $wpdb->insert( + $wpdb->signups, + array( + 'domain' => '', + 'path' => '', + 'title' => '', + 'user_login' => 'legacyuser38474', + 'user_email' => 'legacy38474@example.com', + 'registered' => current_time( 'mysql', true ), + 'activation_key' => $plain_key, + 'meta' => serialize( array() ), + ) + ); + + $result = wpmu_activate_signup( $plain_key ); + + remove_filter( 'wpmu_welcome_user_notification', '__return_false' ); + + $this->assertIsArray( $result, 'Legacy plain-text activation keys must still work after upgrade.' ); + $this->assertArrayHasKey( 'user_id', $result ); + } + + /** + * A wrong key against a legacy plain-text row must still be rejected. + * + * @ticket 38474 + */ + public function test_activate_signup_rejects_wrong_key_against_legacy_row() { + global $wpdb; + + $plain_key = 'correctlegacykey38474'; + $wpdb->insert( + $wpdb->signups, + array( + 'domain' => '', + 'path' => '', + 'title' => '', + 'user_login' => 'legacyuser38474b', + 'user_email' => 'legacy38474b@example.com', + 'registered' => current_time( 'mysql', true ), + 'activation_key' => $plain_key, + 'meta' => serialize( array() ), + ) + ); + + $result = wpmu_activate_signup( 'wrongkey' ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_key', $result->get_error_code() ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_rejects_expired_key() { + $data = $this->signup_user( 'tuser38474e', 'tuser38474e@example.com' ); + + add_filter( + 'activate_signup_expiration', + static function () { + return -1; + } + ); + $result = wpmu_activate_signup( $data['key'] ); + remove_all_filters( 'activate_signup_expiration' ); + + $this->assertWPError( $result ); + $this->assertSame( 'expired_key', $result->get_error_code() ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_expiration_filter_is_applied() { + $data = $this->signup_user( 'tuser38474f', 'tuser38474f@example.com' ); + $filter_called = false; + $filter = static function ( $duration ) use ( &$filter_called ) { + $filter_called = true; + return $duration; + }; + + add_filter( 'activate_signup_expiration', $filter ); + wpmu_activate_signup( $data['key'] ); + remove_filter( 'activate_signup_expiration', $filter ); + + $this->assertTrue( $filter_called ); + } + + /** + * @ticket 38474 + * + * @covers ::wpmu_signup_user_notification + */ + public function test_signup_user_notification_url_contains_key_payload() { + $data = $this->signup_user( 'tuser38474g', 'tuser38474g@example.com' ); + + $captured = ''; + $capture = static function ( $args ) use ( &$captured ) { + $captured = $args['message']; + return $args; + }; + + add_filter( 'wp_mail', $capture ); + wpmu_signup_user_notification( 'tuser38474g', 'tuser38474g@example.com', $data['key'], array() ); + remove_filter( 'wp_mail', $capture ); + + $this->assertStringContainsString( 'wp-activate.php?key=', $captured ); + } +} diff --git a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php index 5c565aad5a016..301259f21e7c2 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php @@ -136,16 +136,21 @@ public function test_should_not_fail_for_existing_signup_with_same_email_if_sign * @ticket 43232 */ public function test_should_not_fail_for_data_used_by_a_deleted_user() { - global $wpdb; - // Don't send notifications. add_filter( 'wpmu_signup_user_notification', '__return_false' ); add_filter( 'wpmu_welcome_user_notification', '__return_false' ); + // Capture the activation payload from the action. + $payload = null; + $listener = static function ( $u, $e, $key ) use ( &$payload ) { + $payload = $key; + }; + add_action( 'after_signup_user', $listener, 10, 3 ); + // Signup, activate and delete new user. wpmu_signup_user( 'foo123', 'foo@example.com' ); - $key = $wpdb->get_var( "SELECT activation_key FROM $wpdb->signups WHERE user_login = 'foo123'" ); - $user = wpmu_activate_signup( $key ); + remove_action( 'after_signup_user', $listener, 10 ); + $user = wpmu_activate_signup( $payload ); wpmu_delete_user( $user['user_id'] ); $valid = wpmu_validate_user_signup( 'foo123', 'foo2@example.com' );