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..b25586ee3574d 100644 --- a/src/wp-includes/ms-functions.php +++ b/src/wp-includes/ms-functions.php @@ -785,6 +785,96 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { return apply_filters( 'wpmu_validate_blog_signup', $result ); } +/** + * Generates an activation key pair for a multisite signup. + * + * Returns a URL-safe base64 payload to embed in the activation link, and a + * base64-encoded HMAC-SHA256 hash to store in the database. The payload encodes + * `email:timestamp:random` and the hash is computed over that same string using + * `AUTH_KEY` + `AUTH_SALT`, so the stored value can be reconstructed from the + * link without storing the random component separately. + * + * The payload uses URL-safe base64 (`-_`, no padding) for embedding in links. + * The hash uses standard base64 (`+/=`) since it is stored in the DB, not URLs. + * Both fit within the legacy `activation_key varchar(50)` column constraint. + * + * Note: Activation links are invalidated when the site's authentication keys or salts + * change, consistent with how WordPress invalidates login sessions on salt rotation. + * + * @since 6.9.0 + * @access private + * + * @param string $user_email The user's email address, embedded in the payload. + * @return array { + * @type string $payload URL-safe base64-encoded activation payload for the email link. + * @type string $hash Base64-encoded HMAC-SHA256 hash to store in the database. + * } + */ +function _wp_generate_signup_key( $user_email ) { + $timestamp = time(); + $random = function_exists( 'random_bytes' ) + ? bin2hex( random_bytes( 8 ) ) + : substr( md5( $timestamp . wp_rand() . $user_email ), 0, 16 ); + $triplet = $user_email . ':' . $timestamp . ':' . $random; + $payload = rtrim( strtr( base64_encode( $triplet ), '+/', '-_' ), '=' ); + $hash = base64_encode( hash_hmac( 'sha256', $triplet, wp_salt( 'auth' ), true ) ); + + return array( + 'key' => $random, + 'payload' => $payload, + 'hash' => $hash, + ); +} + +/** + * Resolves an activation key from an email link into a database lookup value. + * + * Handles both the HMAC-based format introduced in 6.9.0 and legacy plain 16-char + * hex keys from earlier WordPress versions. + * + * @since 6.9.0 + * @access private + * + * @param string $key The activation key from the email link. + * @return array|null { + * Parsed key data, or null if the key format is not recognized. + * + * @type string $format 'new' for HMAC keys, 'legacy' for plain hex keys. + * @type string $hash The value to look up in the `activation_key` column. + * @type string|null $email User email embedded in the key (new format only). + * @type int|null $timestamp Key issuance timestamp embedded in the key (new format only). + * } + */ +function _wp_resolve_signup_key( $key ) { + // 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: email:timestamp:random. + list( $email, $timestamp, $random ) = $parts; + return array( + 'format' => 'new', + 'hash' => base64_encode( hash_hmac( 'sha256', $decoded, wp_salt( 'auth' ), true ) ), + 'email' => $email, + 'timestamp' => (int) $timestamp, + ); + } + + if ( 1 === preg_match( '~^[0-9a-f]{16}$~', $key ) ) { + // Legacy plain-text 16-char hex key — stored directly in the DB before 6.9.0. + return array( + 'format' => 'legacy', + 'hash' => $key, + 'email' => null, + 'timestamp' => null, + ); + } + + return null; +} + /** * Records site signup information for future activation. * @@ -802,7 +892,10 @@ 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 ); + $signup_key = _wp_generate_signup_key( $user_email ); + $key = $signup_key['key']; + $payload = $signup_key['payload']; + $hash = $signup_key['hash']; /** * Filters the metadata for a site signup. @@ -830,7 +923,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 +938,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 ); } /** @@ -871,7 +964,11 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { // Format data. $user = preg_replace( '/\s+/', '', sanitize_user( $user, true ) ); $user_email = sanitize_email( $user_email ); - $key = substr( md5( time() . wp_rand() . $user_email ), 0, 16 ); + + $signup_key = _wp_generate_signup_key( $user_email ); + $key = $signup_key['key']; + $payload = $signup_key['payload']; + $hash = $signup_key['hash']; /** * Filters the metadata for a user signup. @@ -896,7 +993,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 +1005,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,12 +1294,50 @@ function wpmu_activate_signup( ) { global $wpdb; - $signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $key ) ); + $resolved = _wp_resolve_signup_key( $key ); + + if ( null === $resolved ) { + return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); + } + + if ( 'new' === $resolved['format'] ) { + $signup = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM $wpdb->signups WHERE user_email = %s AND activation_key = %s", + $resolved['email'], + $resolved['hash'] + ) + ); + } else { + // Legacy plain-text 16-char hex key — allow for BC with pre-upgrade pending activations. + $signup = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $resolved['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 ); + + // For new keys, use the cryptographically bound timestamp from the payload. + // For legacy keys, fall back to the registered column in the database. + $issued_at = 'new' === $resolved['format'] + ? $resolved['timestamp'] + : (int) mysql2date( 'U', $signup->registered ); + + if ( time() > ( $issued_at + $expiration_duration ) ) { + return new WP_Error( 'expired_key', __( 'Invalid key' ) ); + } + if ( $signup->active ) { if ( empty( $signup->domain ) ) { return new WP_Error( 'already_active', __( 'The user is already active.' ), $signup ); @@ -1235,7 +1370,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 +1412,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'activation_key' => $signup->activation_key ) ); } return $blog_id; @@ -1289,7 +1424,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..d278af164aaa1 --- /dev/null +++ b/tests/phpunit/tests/multisite/wpmuActivateSignup.php @@ -0,0 +1,238 @@ + 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' ); + + // Legacy keys are exactly 16 lowercase hex characters (generated by substr(md5(...), 0, 16)). + $plain_key = 'abc1234567890def'; + $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 = 'abc1234567890abc'; + $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() ), + ) + ); + + // A key that looks like a legacy key but doesn't match the stored value. + $result = wpmu_activate_signup( '0000000000000000' ); + + $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 ); + } + + /** + * A legacy plain-text key registered before the expiration window must be rejected. + * + * @ticket 38474 + */ + public function test_activate_signup_rejects_expired_legacy_key() { + global $wpdb; + + $plain_key = 'aaaabbbbccccdddd'; + $wpdb->insert( + $wpdb->signups, + array( + 'domain' => '', + 'path' => '', + 'title' => '', + 'user_login' => 'legacyuser38474c', + 'user_email' => 'legacy38474c@example.com', + 'registered' => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS - 1 ), + 'activation_key' => $plain_key, + 'meta' => serialize( array() ), + ) + ); + + $result = wpmu_activate_signup( $plain_key ); + + $this->assertWPError( $result ); + $this->assertSame( 'expired_key', $result->get_error_code() ); + } + + /** + * Ensure the stored activation key fits within 60 characters to account for sites + * that have not updated their table schema (e.g. those using DO_NOT_UPGRADE_GLOBAL_TABLES). + * + * @ticket 38474 + */ + public function test_activation_key_fits_legacy_column_length() { + global $wpdb; + + $this->signup_user( 'tuser38474h', 'tuser38474h@example.com' ); + + $stored = $wpdb->get_var( "SELECT activation_key FROM $wpdb->signups WHERE user_login = 'tuser38474h'" ); + + $this->assertNotNull( $stored ); + $this->assertLessThanOrEqual( 60, strlen( $stored ), 'Stored activation key must be no longer than 60 characters for legacy schema compatibility.' ); + } +} 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' );