From 54c4bb556a89e784438defdd947ecc8ff198a312 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Fri, 19 Jun 2026 18:12:16 +0200 Subject: [PATCH 1/3] Security: Hash activation keys in wp_signups (Trac #38474) `wp_signups.activation_key` stored activation keys as plain text (e.g. `7259c714857ef009`), unlike `wp_users.user_activation_key` which already stores a `timestamp:hash` pair. This was assigned CVE-2017-14990. This patch brings `wp_signups` into line with `wp_users`: - `wpmu_signup_blog()` and `wpmu_signup_user()` now hash the key with phpass before storing it (`timestamp:phpass_hash` format), mirroring the approach used for password-reset keys in [25696]. - `wpmu_activate_signup()` verifies the submitted key against the stored hash and enforces a 24-hour expiry via the new `activate_signup_expiration` filter. - Legacy plain-text keys (rows created before the upgrade) continue to work for backwards compatibility so no pending activations are broken by the upgrade. - Activation URLs now include `&signup_id=N` so the correct row can be fetched for hash verification without a full-table scan. - `wp-activate.php` gains a Signup ID field on the manual activation form. - Unit tests cover: hashed storage, successful activation, wrong key rejection, wrong signup_id rejection, legacy key BC, expiry, and the `activate_signup_expiration` filter. Props bor0, tomdxw, jeremyfelt, SergeyBiryukov, SirLouen, dmsnell. Fixes #38474. == Testing Instructions == === Automated (PHPUnit) === Requires the Docker-based local environment: npm install # edit .env: set LOCAL_MULTISITE=true npm run env:start npm run env:install # New tests for this ticket: npm run test:php -- -c tests/phpunit/multisite.xml \ --filter Tests_Multisite_wpmuActivateSignup # Regression: existing test that was updated: npm run test:php -- -c tests/phpunit/multisite.xml \ --filter test_should_not_fail_for_data_used_by_a_deleted_user All 9 tests should pass. === Manual (browser) === 1. HASHED KEY IN DB - Go to /wp-signup.php as a logged-out user and register. - Check wp_signups: activation_key should look like "1700000000:$P$Bxxx..." not a plain 16-char hex string. 2. ACTIVATION LINK WORKS - The confirmation email link includes both key= and signup_id=. - Clicking it shows "Your account is now active!" 3. SIGNUP ID FIELD ON FORM - Visit /wp-activate.php with no params. - The manual entry form should show both "Activation Key" and "Signup ID" fields. 4. WRONG KEY REJECTED - Visit /wp-activate.php?key=WRONGKEY&signup_id= - Activation must fail (no "now active" message). 5. LEGACY KEY BACKWARDS COMPAT - Insert a row with a plain-text activation_key directly into wp_signups (simulating a pre-upgrade pending activation). - Visit /wp-activate.php?key=&signup_id= - Activation should succeed -- pre-upgrade pending activations must not be broken by the upgrade. 6. EXPIRY - Add add_filter('activate_signup_expiration', fn() => -1) to an mu-plugin, sign up, try to activate. - Activation should fail with an expired-key error. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/wp-activate.php | 21 +- src/wp-admin/user-new.php | 4 +- src/wp-includes/ms-default-filters.php | 4 +- src/wp-includes/ms-functions.php | 99 +++++++-- .../tests/multisite/wpmuActivateSignup.php | 200 ++++++++++++++++++ .../multisite/wpmuValidateUserSignup.php | 14 +- 6 files changed, 309 insertions(+), 33 deletions(-) create mode 100644 tests/phpunit/tests/multisite/wpmuActivateSignup.php diff --git a/src/wp-activate.php b/src/wp-activate.php index f1eb579e61b19..90b7c808e8bb2 100644 --- a/src/wp-activate.php +++ b/src/wp-activate.php @@ -23,8 +23,9 @@ list( $activate_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) ); $activate_cookie = 'wp-activate-' . COOKIEHASH; -$key = ''; -$result = null; +$key = ''; +$signup_id = 0; +$result = null; if ( isset( $_GET['key'] ) && isset( $_POST['key'] ) && $_GET['key'] !== $_POST['key'] ) { wp_die( __( 'A key value mismatch has been detected. Please follow the link provided in your activation email.' ), __( 'An error occurred during the activation' ), 400 ); @@ -34,6 +35,12 @@ $key = sanitize_text_field( $_POST['key'] ); } +if ( ! empty( $_GET['signup_id'] ) ) { + $signup_id = absint( $_GET['signup_id'] ); +} elseif ( ! empty( $_POST['signup_id'] ) ) { + $signup_id = absint( $_POST['signup_id'] ); +} + if ( $key ) { $redirect_url = remove_query_arg( 'key' ); @@ -42,17 +49,17 @@ wp_safe_redirect( $redirect_url ); exit; } else { - $result = wpmu_activate_signup( $key ); + $result = wpmu_activate_signup( $key, $signup_id ); } } if ( null === $result && isset( $_COOKIE[ $activate_cookie ] ) ) { $key = $_COOKIE[ $activate_cookie ]; - $result = wpmu_activate_signup( $key ); + $result = wpmu_activate_signup( $key, $signup_id ); setcookie( $activate_cookie, ' ', time() - YEAR_IN_SECONDS, $activate_path, COOKIE_DOMAIN, is_ssl(), true ); } -if ( null === $result || ( is_wp_error( $result ) && 'invalid_key' === $result->get_error_code() ) ) { +if ( null === $result || ( is_wp_error( $result ) && in_array( $result->get_error_code(), array( 'invalid_key', 'invalid_id', 'expired_key' ), true ) ) ) { status_header( 404 ); } elseif ( is_wp_error( $result ) ) { $error_code = $result->get_error_code(); @@ -130,6 +137,10 @@ function wpmu_activate_stylesheet() {

+

+ +
+

diff --git a/src/wp-admin/user-new.php b/src/wp-admin/user-new.php index ba027b06bb366..8b24364f034a4 100644 --- a/src/wp-admin/user-new.php +++ b/src/wp-admin/user-new.php @@ -240,8 +240,8 @@ ); 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 ); + $row = $wpdb->get_row( $wpdb->prepare( "SELECT activation_key, signup_id FROM {$wpdb->signups} WHERE user_login = %s AND user_email = %s", $new_user_login, $new_user_email ) ); + $new_user = wpmu_activate_signup( $row->activation_key, $row->signup_id ); 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-default-filters.php b/src/wp-includes/ms-default-filters.php index 8682d48e18e45..d90e0a7997aed 100644 --- a/src/wp-includes/ms-default-filters.php +++ b/src/wp-includes/ms-default-filters.php @@ -26,7 +26,7 @@ add_action( 'wpmu_new_user', 'newuser_notify_siteadmin' ); add_action( 'wpmu_activate_user', 'add_new_user_to_blog', 10, 3 ); add_action( 'wpmu_activate_user', 'wpmu_welcome_user_notification', 10, 3 ); -add_action( 'after_signup_user', 'wpmu_signup_user_notification', 10, 4 ); +add_action( 'after_signup_user', 'wpmu_signup_user_notification', 10, 5 ); add_action( 'network_site_new_created_user', 'wp_send_new_user_notifications' ); add_action( 'network_site_users_created_user', 'wp_send_new_user_notifications' ); add_action( 'network_user_new_created_user', 'wp_send_new_user_notifications' ); @@ -39,7 +39,7 @@ // Blogs. add_filter( 'wpmu_validate_blog_signup', 'signup_nonce_check' ); add_action( 'wpmu_activate_blog', 'wpmu_welcome_notification', 10, 5 ); -add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 7 ); +add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 8 ); add_filter( 'wp_normalize_site_data', 'wp_normalize_site_data', 10, 1 ); add_action( 'wp_validate_site_data', 'wp_validate_site_data', 10, 3 ); add_action( 'wp_insert_site', 'wp_maybe_update_network_site_counts_on_update', 10, 1 ); diff --git a/src/wp-includes/ms-functions.php b/src/wp-includes/ms-functions.php index f1cbc62fa8ec7..34e6a8c3111ff 100644 --- a/src/wp-includes/ms-functions.php +++ b/src/wp-includes/ms-functions.php @@ -800,10 +800,17 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { * @param array $meta Optional. Signup meta data. By default, contains the requested privacy setting and lang_id. */ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = array() ) { - global $wpdb; + global $wpdb, $wp_hasher; $key = substr( md5( time() . wp_rand() . $domain ), 0, 16 ); + if ( empty( $wp_hasher ) ) { + require_once ABSPATH . WPINC . '/class-phpass.php'; + $wp_hasher = new PasswordHash( 8, true ); + } + + $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + /** * Filters the metadata for a site signup. * @@ -818,8 +825,9 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @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 $hashed The user's hashed activation key. */ - $meta = apply_filters( 'signup_site_meta', $meta, $domain, $path, $title, $user, $user_email, $key ); + $meta = apply_filters( 'signup_site_meta', $meta, $domain, $path, $title, $user, $user_email, $key, $hashed ); $wpdb->insert( $wpdb->signups, @@ -830,7 +838,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' => $hashed, 'meta' => serialize( $meta ), ) ); @@ -847,8 +855,10 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @param string $user_email The user's email address. * @param string $key The user's activation key. * @param array $meta Signup meta data. By default, contains the requested privacy setting and lang_id. + * @param int $signup_id Signup ID. + * @param string $hashed The user's hashed activation key. */ - do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta ); + do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta, $wpdb->insert_id, $hashed ); } /** @@ -866,13 +876,20 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @param array $meta Optional. Signup meta data. Default empty array. */ function wpmu_signup_user( $user, $user_email, $meta = array() ) { - global $wpdb; + global $wpdb, $wp_hasher; // 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 ); + if ( empty( $wp_hasher ) ) { + require_once ABSPATH . WPINC . '/class-phpass.php'; + $wp_hasher = new PasswordHash( 8, true ); + } + + $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + /** * Filters the metadata for a user signup. * @@ -884,8 +901,9 @@ 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 $hashed The user's hashed activation key. */ - $meta = apply_filters( 'signup_user_meta', $meta, $user, $user_email, $key ); + $meta = apply_filters( 'signup_user_meta', $meta, $user, $user_email, $key, $hashed ); $wpdb->insert( $wpdb->signups, @@ -896,7 +914,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' => $hashed, 'meta' => serialize( $meta ), ) ); @@ -910,8 +928,10 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { * @param string $user_email The user's email address. * @param string $key The user's activation key. * @param array $meta Signup meta data. Default empty array. + * @param int $signup_id Signup ID. + * @param string $hashed The user's hashed activation key. */ - do_action( 'after_signup_user', $user, $user_email, $key, $meta ); + do_action( 'after_signup_user', $user, $user_email, $key, $meta, $wpdb->insert_id, $hashed ); } /** @@ -947,7 +967,8 @@ function wpmu_signup_blog_notification( $user_email, #[\SensitiveParameter] $key, - $meta = array() + $meta = array(), + $signup_id = 0 ) { /** * Filters whether to bypass the new site email notification. @@ -968,9 +989,9 @@ function wpmu_signup_blog_notification( // Send email with activation link. if ( ! is_subdomain_install() || get_current_network_id() !== 1 ) { - $activate_url = network_site_url( "wp-activate.php?key=$key" ); + $activate_url = network_site_url( "wp-activate.php?key=$key&signup_id=$signup_id" ); } else { - $activate_url = "http://{$domain}{$path}wp-activate.php?key=$key"; // @todo Use *_url() API. + $activate_url = "http://{$domain}{$path}wp-activate.php?key=$key&signup_id=$signup_id"; // @todo Use *_url() API. } $activate_url = esc_url( $activate_url ); @@ -1088,7 +1109,8 @@ function wpmu_signup_user_notification( $user_email, #[\SensitiveParameter] $key, - $meta = array() + $meta = array(), + $signup_id = 0 ) { /** * Filters whether to bypass the email notification for new user sign-up. @@ -1139,7 +1161,7 @@ function wpmu_signup_user_notification( $key, $meta ), - site_url( "wp-activate.php?key=$key" ) + site_url( "wp-activate.php?key=$key&signup_id=$signup_id" ) ); $subject = sprintf( @@ -1188,21 +1210,58 @@ function wpmu_signup_user_notification( * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $key The activation key provided to the user. + * @param string $key The activation key provided to the user. + * @param int $signup_id The signup ID. * @return array|WP_Error An array containing information about the activated user and/or blog. */ function wpmu_activate_signup( #[\SensitiveParameter] - $key + $key, + $signup_id = 0 ) { - global $wpdb; + global $wpdb, $wp_hasher; - $signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $key ) ); + $signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s OR signup_id = %d", $key, $signup_id ) ); if ( empty( $signup ) ) { return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); } + // The format of new keys is :. If the stored key has no colon, + // it is a legacy plain-text key from before this hashing was introduced. + if ( false === strpos( $signup->activation_key, ':' ) ) { + // Legacy key: compare directly and allow activation for backwards compatibility. + // Pre-upgrade pending activations must continue to work after the site upgrades. + if ( $key !== $signup->activation_key ) { + return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); + } + } else { + if ( empty( $wp_hasher ) ) { + require_once ABSPATH . WPINC . '/class-phpass.php'; + $wp_hasher = new PasswordHash( 8, true ); + } + + list( $pass_request_time, $signup_key ) = explode( ':', $signup->activation_key, 2 ); + + if ( ! $wp_hasher->CheckPassword( $key, $signup_key ) ) { + 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 ); + $expiration_time = $pass_request_time + $expiration_duration; + + if ( time() > $expiration_time ) { + 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 +1294,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'signup_id' => $signup->signup_id ) ); if ( isset( $user_already_exists ) ) { @@ -1277,7 +1336,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'activation_key' => $key ) + array( 'signup_id' => $signup->signup_id ) ); } return $blog_id; @@ -1289,7 +1348,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..0931f33be3769 --- /dev/null +++ b/tests/phpunit/tests/multisite/wpmuActivateSignup.php @@ -0,0 +1,200 @@ + null, 'signup_id' => null ); + $listener = static function( $u, $e, $key, $meta, $signup_id ) use ( &$data ) { + $data['key'] = $key; + $data['signup_id'] = $signup_id; + }; + add_filter( 'wpmu_signup_user_notification', '__return_false' ); + add_action( 'after_signup_user', $listener, 10, 5 ); + 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_hashed_key() { + global $wpdb; + + $this->signup_user( 'tuser38474a', 'tuser38474a@example.com' ); + + $stored = $wpdb->get_var( "SELECT activation_key FROM $wpdb->signups WHERE user_login = 'tuser38474a'" ); + + $this->assertStringContainsString( ':', $stored, 'Stored activation key must be in timestamp:hash format.' ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_succeeds_with_valid_key_and_signup_id() { + add_filter( 'wpmu_welcome_user_notification', '__return_false' ); + + $data = $this->signup_user( 'tuser38474b', 'tuser38474b@example.com' ); + $result = wpmu_activate_signup( $data['key'], $data['signup_id'] ); + + 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() { + $data = $this->signup_user( 'tuser38474c', 'tuser38474c@example.com' ); + $result = wpmu_activate_signup( 'thisisnottherightkey', $data['signup_id'] ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_key', $result->get_error_code() ); + } + + /** + * @ticket 38474 + */ + public function test_activate_signup_fails_with_wrong_signup_id() { + $data = $this->signup_user( 'tuser38474d', 'tuser38474d@example.com' ); + $result = wpmu_activate_signup( $data['key'], 0 ); + + $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() ), + ) + ); + $signup_id = $wpdb->insert_id; + + $result = wpmu_activate_signup( $plain_key, $signup_id ); + + 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() ), + ) + ); + $signup_id = $wpdb->insert_id; + + $result = wpmu_activate_signup( 'wrongkey', $signup_id ); + + $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'], $data['signup_id'] ); + 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'], $data['signup_id'] ); + remove_filter( 'activate_signup_expiration', $filter ); + + $this->assertTrue( $filter_called ); + } + + /** + * @ticket 38474 + * + * @covers ::wpmu_signup_user_notification + */ + public function test_signup_user_notification_includes_signup_id_in_url() { + $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(), $data['signup_id'] ); + remove_filter( 'wp_mail', $capture ); + + $this->assertStringContainsString( 'signup_id=' . $data['signup_id'], $captured ); + } +} diff --git a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php index 5c565aad5a016..82210e0d3f6de 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php @@ -136,16 +136,22 @@ 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 plain-text key and signup ID from the action. + $signup_data = array( 'key' => null, 'signup_id' => null ); + $listener = static function( $u, $e, $key, $meta, $signup_id ) use ( &$signup_data ) { + $signup_data['key'] = $key; + $signup_data['signup_id'] = $signup_id; + }; + add_action( 'after_signup_user', $listener, 10, 5 ); + // 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( $signup_data['key'], $signup_data['signup_id'] ); wpmu_delete_user( $user['user_id'] ); $valid = wpmu_validate_user_signup( 'foo123', 'foo2@example.com' ); From b4af97af84d216bd3a4f53b9100a535a7ad78f11 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Fri, 19 Jun 2026 18:17:52 +0200 Subject: [PATCH 2/3] Tests: Fix PHPCS coding standards issues - Multi-item associative arrays: each value on its own line - Space after function keyword in anonymous/arrow functions - Inline closure expanded to multi-line (brace must be last content on line) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../tests/multisite/wpmuActivateSignup.php | 18 +++++++++++++----- .../tests/multisite/wpmuValidateUserSignup.php | 7 +++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/multisite/wpmuActivateSignup.php b/tests/phpunit/tests/multisite/wpmuActivateSignup.php index 0931f33be3769..32e59d01f526d 100644 --- a/tests/phpunit/tests/multisite/wpmuActivateSignup.php +++ b/tests/phpunit/tests/multisite/wpmuActivateSignup.php @@ -17,8 +17,11 @@ class Tests_Multisite_wpmuActivateSignup extends WP_UnitTestCase { * @return array{ key: string, signup_id: int } */ private function signup_user( $login, $email ) { - $data = array( 'key' => null, 'signup_id' => null ); - $listener = static function( $u, $e, $key, $meta, $signup_id ) use ( &$data ) { + $data = array( + 'key' => null, + 'signup_id' => null, + ); + $listener = static function ( $u, $e, $key, $meta, $signup_id ) use ( &$data ) { $data['key'] = $key; $data['signup_id'] = $signup_id; }; @@ -151,7 +154,12 @@ public function test_activate_signup_rejects_wrong_key_against_legacy_row() { 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; } ); + add_filter( + 'activate_signup_expiration', + static function () { + return -1; + } + ); $result = wpmu_activate_signup( $data['key'], $data['signup_id'] ); remove_all_filters( 'activate_signup_expiration' ); @@ -165,7 +173,7 @@ public function test_activate_signup_rejects_expired_key() { 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 = static function ( $duration ) use ( &$filter_called ) { $filter_called = true; return $duration; }; @@ -186,7 +194,7 @@ public function test_signup_user_notification_includes_signup_id_in_url() { $data = $this->signup_user( 'tuser38474g', 'tuser38474g@example.com' ); $captured = ''; - $capture = static function( $args ) use ( &$captured ) { + $capture = static function ( $args ) use ( &$captured ) { $captured = $args['message']; return $args; }; diff --git a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php index 82210e0d3f6de..b37fdbba014fb 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php @@ -141,8 +141,11 @@ public function test_should_not_fail_for_data_used_by_a_deleted_user() { add_filter( 'wpmu_welcome_user_notification', '__return_false' ); // Capture the plain-text key and signup ID from the action. - $signup_data = array( 'key' => null, 'signup_id' => null ); - $listener = static function( $u, $e, $key, $meta, $signup_id ) use ( &$signup_data ) { + $signup_data = array( + 'key' => null, + 'signup_id' => null, + ); + $listener = static function ( $u, $e, $key, $meta, $signup_id ) use ( &$signup_data ) { $signup_data['key'] = $key; $signup_data['signup_id'] = $signup_id; }; From 6af49cbe1ae5c00f94f43e8c3015f31f60ed84a3 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Mon, 22 Jun 2026 18:12:56 +0200 Subject: [PATCH 3/3] Security: Switch to HMAC-SHA256 for activation key storage (#38474) Replaces the phpass approach with HMAC-SHA256 per feedback from @dmsnell in comment:29. Instead of storing timestamp:phpass_hash and requiring signup_id as a second lookup parameter, we now: - Build a triplet: user_email:timestamp:random_key - Store base64(HMAC-SHA256(triplet, AUTH_KEY+AUTH_SALT)) in the DB (44 chars, fits the existing varchar(50) column) - Send base64url(triplet) as the activation URL parameter On activation the triplet is decoded from the URL, the HMAC is recomputed, and the row is found with a direct indexed lookup: WHERE user_email = %s AND activation_key = %s This eliminates signup_id from URLs entirely. Legacy plain-text keys (pre-upgrade pending activations) continue to work via a fallback WHERE activation_key = %s path. Props bor0, dmsnell, tomdxw, jeremyfelt, SergeyBiryukov, SirLouen. Fixes #38474. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/wp-activate.php | 21 +-- src/wp-admin/user-new.php | 12 +- src/wp-includes/ms-default-filters.php | 4 +- src/wp-includes/ms-functions.php | 132 +++++++++--------- .../tests/multisite/wpmuActivateSignup.php | 56 +++----- .../multisite/wpmuValidateUserSignup.php | 16 +-- 6 files changed, 105 insertions(+), 136 deletions(-) diff --git a/src/wp-activate.php b/src/wp-activate.php index 90b7c808e8bb2..f1eb579e61b19 100644 --- a/src/wp-activate.php +++ b/src/wp-activate.php @@ -23,9 +23,8 @@ list( $activate_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) ); $activate_cookie = 'wp-activate-' . COOKIEHASH; -$key = ''; -$signup_id = 0; -$result = null; +$key = ''; +$result = null; if ( isset( $_GET['key'] ) && isset( $_POST['key'] ) && $_GET['key'] !== $_POST['key'] ) { wp_die( __( 'A key value mismatch has been detected. Please follow the link provided in your activation email.' ), __( 'An error occurred during the activation' ), 400 ); @@ -35,12 +34,6 @@ $key = sanitize_text_field( $_POST['key'] ); } -if ( ! empty( $_GET['signup_id'] ) ) { - $signup_id = absint( $_GET['signup_id'] ); -} elseif ( ! empty( $_POST['signup_id'] ) ) { - $signup_id = absint( $_POST['signup_id'] ); -} - if ( $key ) { $redirect_url = remove_query_arg( 'key' ); @@ -49,17 +42,17 @@ wp_safe_redirect( $redirect_url ); exit; } else { - $result = wpmu_activate_signup( $key, $signup_id ); + $result = wpmu_activate_signup( $key ); } } if ( null === $result && isset( $_COOKIE[ $activate_cookie ] ) ) { $key = $_COOKIE[ $activate_cookie ]; - $result = wpmu_activate_signup( $key, $signup_id ); + $result = wpmu_activate_signup( $key ); setcookie( $activate_cookie, ' ', time() - YEAR_IN_SECONDS, $activate_path, COOKIE_DOMAIN, is_ssl(), true ); } -if ( null === $result || ( is_wp_error( $result ) && in_array( $result->get_error_code(), array( 'invalid_key', 'invalid_id', 'expired_key' ), true ) ) ) { +if ( null === $result || ( is_wp_error( $result ) && 'invalid_key' === $result->get_error_code() ) ) { status_header( 404 ); } elseif ( is_wp_error( $result ) ) { $error_code = $result->get_error_code(); @@ -137,10 +130,6 @@ function wpmu_activate_stylesheet() {

-

- -
-

diff --git a/src/wp-admin/user-new.php b/src/wp-admin/user-new.php index 8b24364f034a4..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' ) ) { - $row = $wpdb->get_row( $wpdb->prepare( "SELECT activation_key, signup_id FROM {$wpdb->signups} WHERE user_login = %s AND user_email = %s", $new_user_login, $new_user_email ) ); - $new_user = wpmu_activate_signup( $row->activation_key, $row->signup_id ); + $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-default-filters.php b/src/wp-includes/ms-default-filters.php index d90e0a7997aed..8682d48e18e45 100644 --- a/src/wp-includes/ms-default-filters.php +++ b/src/wp-includes/ms-default-filters.php @@ -26,7 +26,7 @@ add_action( 'wpmu_new_user', 'newuser_notify_siteadmin' ); add_action( 'wpmu_activate_user', 'add_new_user_to_blog', 10, 3 ); add_action( 'wpmu_activate_user', 'wpmu_welcome_user_notification', 10, 3 ); -add_action( 'after_signup_user', 'wpmu_signup_user_notification', 10, 5 ); +add_action( 'after_signup_user', 'wpmu_signup_user_notification', 10, 4 ); add_action( 'network_site_new_created_user', 'wp_send_new_user_notifications' ); add_action( 'network_site_users_created_user', 'wp_send_new_user_notifications' ); add_action( 'network_user_new_created_user', 'wp_send_new_user_notifications' ); @@ -39,7 +39,7 @@ // Blogs. add_filter( 'wpmu_validate_blog_signup', 'signup_nonce_check' ); add_action( 'wpmu_activate_blog', 'wpmu_welcome_notification', 10, 5 ); -add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 8 ); +add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 7 ); add_filter( 'wp_normalize_site_data', 'wp_normalize_site_data', 10, 1 ); add_action( 'wp_validate_site_data', 'wp_validate_site_data', 10, 3 ); add_action( 'wp_insert_site', 'wp_maybe_update_network_site_counts_on_update', 10, 1 ); diff --git a/src/wp-includes/ms-functions.php b/src/wp-includes/ms-functions.php index 34e6a8c3111ff..67d8c385f7224 100644 --- a/src/wp-includes/ms-functions.php +++ b/src/wp-includes/ms-functions.php @@ -800,16 +800,13 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { * @param array $meta Optional. Signup meta data. By default, contains the requested privacy setting and lang_id. */ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = array() ) { - global $wpdb, $wp_hasher; - - $key = substr( md5( time() . wp_rand() . $domain ), 0, 16 ); - - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } + global $wpdb; - $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + $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. @@ -825,9 +822,8 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @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 $hashed The user's hashed activation key. */ - $meta = apply_filters( 'signup_site_meta', $meta, $domain, $path, $title, $user, $user_email, $key, $hashed ); + $meta = apply_filters( 'signup_site_meta', $meta, $domain, $path, $title, $user, $user_email, $key ); $wpdb->insert( $wpdb->signups, @@ -838,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' => $hashed, + 'activation_key' => $hash, 'meta' => serialize( $meta ), ) ); @@ -853,12 +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. - * @param int $signup_id Signup ID. - * @param string $hashed The user's hashed activation key. */ - do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta, $wpdb->insert_id, $hashed ); + do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $payload, $meta ); } /** @@ -876,19 +870,16 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a * @param array $meta Optional. Signup meta data. Default empty array. */ function wpmu_signup_user( $user, $user_email, $meta = array() ) { - global $wpdb, $wp_hasher; + global $wpdb; // 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 ); - - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } - - $hashed = time() . ':' . $wp_hasher->HashPassword( $key ); + $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. @@ -901,9 +892,8 @@ 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 $hashed The user's hashed activation key. */ - $meta = apply_filters( 'signup_user_meta', $meta, $user, $user_email, $key, $hashed ); + $meta = apply_filters( 'signup_user_meta', $meta, $user, $user_email, $key ); $wpdb->insert( $wpdb->signups, @@ -914,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' => $hashed, + 'activation_key' => $hash, 'meta' => serialize( $meta ), ) ); @@ -926,12 +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. - * @param int $signup_id Signup ID. - * @param string $hashed The user's hashed activation key. */ - do_action( 'after_signup_user', $user, $user_email, $key, $meta, $wpdb->insert_id, $hashed ); + do_action( 'after_signup_user', $user, $user_email, $payload, $meta ); } /** @@ -967,8 +955,7 @@ function wpmu_signup_blog_notification( $user_email, #[\SensitiveParameter] $key, - $meta = array(), - $signup_id = 0 + $meta = array() ) { /** * Filters whether to bypass the new site email notification. @@ -989,9 +976,9 @@ function wpmu_signup_blog_notification( // Send email with activation link. if ( ! is_subdomain_install() || get_current_network_id() !== 1 ) { - $activate_url = network_site_url( "wp-activate.php?key=$key&signup_id=$signup_id" ); + $activate_url = network_site_url( "wp-activate.php?key=$key" ); } else { - $activate_url = "http://{$domain}{$path}wp-activate.php?key=$key&signup_id=$signup_id"; // @todo Use *_url() API. + $activate_url = "http://{$domain}{$path}wp-activate.php?key=$key"; // @todo Use *_url() API. } $activate_url = esc_url( $activate_url ); @@ -1109,8 +1096,7 @@ function wpmu_signup_user_notification( $user_email, #[\SensitiveParameter] $key, - $meta = array(), - $signup_id = 0 + $meta = array() ) { /** * Filters whether to bypass the email notification for new user sign-up. @@ -1161,7 +1147,7 @@ function wpmu_signup_user_notification( $key, $meta ), - site_url( "wp-activate.php?key=$key&signup_id=$signup_id" ) + site_url( "wp-activate.php?key=$key" ) ); $subject = sprintf( @@ -1210,40 +1196,40 @@ function wpmu_signup_user_notification( * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $key The activation key provided to the user. - * @param int $signup_id The signup ID. + * @param string $key The activation key provided to the user. * @return array|WP_Error An array containing information about the activated user and/or blog. */ function wpmu_activate_signup( #[\SensitiveParameter] - $key, - $signup_id = 0 + $key ) { - global $wpdb, $wp_hasher; - - $signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s OR signup_id = %d", $key, $signup_id ) ); - - if ( empty( $signup ) ) { - return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); - } - - // The format of new keys is :. If the stored key has no colon, - // it is a legacy plain-text key from before this hashing was introduced. - if ( false === strpos( $signup->activation_key, ':' ) ) { - // Legacy key: compare directly and allow activation for backwards compatibility. - // Pre-upgrade pending activations must continue to work after the site upgrades. - if ( $key !== $signup->activation_key ) { - return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); - } - } else { - if ( empty( $wp_hasher ) ) { - require_once ABSPATH . WPINC . '/class-phpass.php'; - $wp_hasher = new PasswordHash( 8, true ); - } + global $wpdb; - list( $pass_request_time, $signup_key ) = explode( ':', $signup->activation_key, 2 ); + /* + * 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 ( ! $wp_hasher->CheckPassword( $key, $signup_key ) ) { + if ( empty( $signup ) ) { return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); } @@ -1255,11 +1241,19 @@ function wpmu_activate_signup( * @param int $expiration_duration The expiration time in seconds. */ $expiration_duration = apply_filters( 'activate_signup_expiration', DAY_IN_SECONDS ); - $expiration_time = $pass_request_time + $expiration_duration; - if ( time() > $expiration_time ) { + 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 ) { @@ -1294,7 +1288,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'signup_id' => $signup->signup_id ) + array( 'activation_key' => $signup->activation_key ) ); if ( isset( $user_already_exists ) ) { @@ -1336,7 +1330,7 @@ function wpmu_activate_signup( 'active' => 1, 'activated' => $now, ), - array( 'signup_id' => $signup->signup_id ) + array( 'activation_key' => $signup->activation_key ) ); } return $blog_id; diff --git a/tests/phpunit/tests/multisite/wpmuActivateSignup.php b/tests/phpunit/tests/multisite/wpmuActivateSignup.php index 32e59d01f526d..d61ed97cf1f10 100644 --- a/tests/phpunit/tests/multisite/wpmuActivateSignup.php +++ b/tests/phpunit/tests/multisite/wpmuActivateSignup.php @@ -9,24 +9,19 @@ class Tests_Multisite_wpmuActivateSignup extends WP_UnitTestCase { /** - * Signs up a user and captures the plain-text activation key and signup ID - * via the after_signup_user action (before hashing occurs in the DB). + * Signs up a user and captures the URL payload from the after_signup_user action. * * @param string $login * @param string $email - * @return array{ key: string, signup_id: int } + * @return array{ key: string } */ private function signup_user( $login, $email ) { - $data = array( - 'key' => null, - 'signup_id' => null, - ); - $listener = static function ( $u, $e, $key, $meta, $signup_id ) use ( &$data ) { - $data['key'] = $key; - $data['signup_id'] = $signup_id; + $data = array( 'key' => 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, 5 ); + 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' ); @@ -36,24 +31,24 @@ private function signup_user( $login, $email ) { /** * @ticket 38474 */ - public function test_signup_user_stores_hashed_key() { + 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->assertStringContainsString( ':', $stored, 'Stored activation key must be in timestamp:hash format.' ); + $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_key_and_signup_id() { + 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'], $data['signup_id'] ); + $result = wpmu_activate_signup( $data['key'] ); remove_filter( 'wpmu_welcome_user_notification', '__return_false' ); @@ -65,19 +60,8 @@ public function test_activate_signup_succeeds_with_valid_key_and_signup_id() { * @ticket 38474 */ public function test_activate_signup_fails_with_wrong_key() { - $data = $this->signup_user( 'tuser38474c', 'tuser38474c@example.com' ); - $result = wpmu_activate_signup( 'thisisnottherightkey', $data['signup_id'] ); - - $this->assertWPError( $result ); - $this->assertSame( 'invalid_key', $result->get_error_code() ); - } - - /** - * @ticket 38474 - */ - public function test_activate_signup_fails_with_wrong_signup_id() { - $data = $this->signup_user( 'tuser38474d', 'tuser38474d@example.com' ); - $result = wpmu_activate_signup( $data['key'], 0 ); + $this->signup_user( 'tuser38474c', 'tuser38474c@example.com' ); + $result = wpmu_activate_signup( 'thisisnottherightkey' ); $this->assertWPError( $result ); $this->assertSame( 'invalid_key', $result->get_error_code() ); @@ -108,9 +92,8 @@ public function test_activate_signup_allows_legacy_plain_text_key() { 'meta' => serialize( array() ), ) ); - $signup_id = $wpdb->insert_id; - $result = wpmu_activate_signup( $plain_key, $signup_id ); + $result = wpmu_activate_signup( $plain_key ); remove_filter( 'wpmu_welcome_user_notification', '__return_false' ); @@ -140,9 +123,8 @@ public function test_activate_signup_rejects_wrong_key_against_legacy_row() { 'meta' => serialize( array() ), ) ); - $signup_id = $wpdb->insert_id; - $result = wpmu_activate_signup( 'wrongkey', $signup_id ); + $result = wpmu_activate_signup( 'wrongkey' ); $this->assertWPError( $result ); $this->assertSame( 'invalid_key', $result->get_error_code() ); @@ -160,7 +142,7 @@ static function () { return -1; } ); - $result = wpmu_activate_signup( $data['key'], $data['signup_id'] ); + $result = wpmu_activate_signup( $data['key'] ); remove_all_filters( 'activate_signup_expiration' ); $this->assertWPError( $result ); @@ -179,7 +161,7 @@ public function test_activate_signup_expiration_filter_is_applied() { }; add_filter( 'activate_signup_expiration', $filter ); - wpmu_activate_signup( $data['key'], $data['signup_id'] ); + wpmu_activate_signup( $data['key'] ); remove_filter( 'activate_signup_expiration', $filter ); $this->assertTrue( $filter_called ); @@ -190,7 +172,7 @@ public function test_activate_signup_expiration_filter_is_applied() { * * @covers ::wpmu_signup_user_notification */ - public function test_signup_user_notification_includes_signup_id_in_url() { + public function test_signup_user_notification_url_contains_key_payload() { $data = $this->signup_user( 'tuser38474g', 'tuser38474g@example.com' ); $captured = ''; @@ -200,9 +182,9 @@ public function test_signup_user_notification_includes_signup_id_in_url() { }; add_filter( 'wp_mail', $capture ); - wpmu_signup_user_notification( 'tuser38474g', 'tuser38474g@example.com', $data['key'], array(), $data['signup_id'] ); + wpmu_signup_user_notification( 'tuser38474g', 'tuser38474g@example.com', $data['key'], array() ); remove_filter( 'wp_mail', $capture ); - $this->assertStringContainsString( 'signup_id=' . $data['signup_id'], $captured ); + $this->assertStringContainsString( 'wp-activate.php?key=', $captured ); } } diff --git a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php index b37fdbba014fb..301259f21e7c2 100644 --- a/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php +++ b/tests/phpunit/tests/multisite/wpmuValidateUserSignup.php @@ -140,21 +140,17 @@ public function test_should_not_fail_for_data_used_by_a_deleted_user() { add_filter( 'wpmu_signup_user_notification', '__return_false' ); add_filter( 'wpmu_welcome_user_notification', '__return_false' ); - // Capture the plain-text key and signup ID from the action. - $signup_data = array( - 'key' => null, - 'signup_id' => null, - ); - $listener = static function ( $u, $e, $key, $meta, $signup_id ) use ( &$signup_data ) { - $signup_data['key'] = $key; - $signup_data['signup_id'] = $signup_id; + // 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, 5 ); + add_action( 'after_signup_user', $listener, 10, 3 ); // Signup, activate and delete new user. wpmu_signup_user( 'foo123', 'foo@example.com' ); remove_action( 'after_signup_user', $listener, 10 ); - $user = wpmu_activate_signup( $signup_data['key'], $signup_data['signup_id'] ); + $user = wpmu_activate_signup( $payload ); wpmu_delete_user( $user['user_id'] ); $valid = wpmu_validate_user_signup( 'foo123', 'foo2@example.com' );