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' );