Skip to content
12 changes: 10 additions & 2 deletions src/wp-admin/user-new.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -239,9 +246,10 @@
)
);

remove_action( 'after_signup_user', $capture_payload, 10 );

if ( isset( $_POST['noconfirmation'] ) && current_user_can( 'manage_network_users' ) ) {
$key = $wpdb->get_var( $wpdb->prepare( "SELECT activation_key FROM {$wpdb->signups} WHERE user_login = %s AND user_email = %s", $new_user_login, $new_user_email ) );
$new_user = wpmu_activate_signup( $key );
$new_user = wpmu_activate_signup( $activation_payload );
if ( is_wp_error( $new_user ) ) {
$redirect = add_query_arg( array( 'update' => 'addnoconfirmation' ), 'user-new.php' );
} elseif ( ! is_user_member_of_blog( $new_user['user_id'] ) ) {
Expand Down
159 changes: 147 additions & 12 deletions src/wp-includes/ms-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,96 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) {
return apply_filters( 'wpmu_validate_blog_signup', $result );
}

/**
* Generates an activation key pair for a multisite signup.
*
* Returns a URL-safe base64 payload to embed in the activation link, and a
* base64-encoded HMAC-SHA256 hash to store in the database. The payload encodes
* `email:timestamp:random` and the hash is computed over that same string using
* `AUTH_KEY` + `AUTH_SALT`, so the stored value can be reconstructed from the
* link without storing the random component separately.
*
* The payload uses URL-safe base64 (`-_`, no padding) for embedding in links.
* The hash uses standard base64 (`+/=`) since it is stored in the DB, not URLs.
* Both fit within the legacy `activation_key varchar(50)` column constraint.
*
* Note: Activation links are invalidated when the site's authentication keys or salts
* change, consistent with how WordPress invalidates login sessions on salt rotation.
*
* @since 6.9.0
* @access private
*
* @param string $user_email The user's email address, embedded in the payload.
* @return array {
* @type string $payload URL-safe base64-encoded activation payload for the email link.
* @type string $hash Base64-encoded HMAC-SHA256 hash to store in the database.
* }
*/
function _wp_generate_signup_key( $user_email ) {
$timestamp = time();
$random = function_exists( 'random_bytes' )
? bin2hex( random_bytes( 8 ) )
: substr( md5( $timestamp . wp_rand() . $user_email ), 0, 16 );
$triplet = $user_email . ':' . $timestamp . ':' . $random;
$payload = rtrim( strtr( base64_encode( $triplet ), '+/', '-_' ), '=' );
$hash = base64_encode( hash_hmac( 'sha256', $triplet, wp_salt( 'auth' ), true ) );

return array(
'key' => $random,
'payload' => $payload,
'hash' => $hash,
);
}

/**
* Resolves an activation key from an email link into a database lookup value.
*
* Handles both the HMAC-based format introduced in 6.9.0 and legacy plain 16-char
* hex keys from earlier WordPress versions.
*
* @since 6.9.0
* @access private
*
* @param string $key The activation key from the email link.
* @return array|null {
* Parsed key data, or null if the key format is not recognized.
*
* @type string $format 'new' for HMAC keys, 'legacy' for plain hex keys.
* @type string $hash The value to look up in the `activation_key` column.
* @type string|null $email User email embedded in the key (new format only).
* @type int|null $timestamp Key issuance timestamp embedded in the key (new format only).
* }
*/
function _wp_resolve_signup_key( $key ) {
// Restore base64 padding before decoding (it was stripped when the payload was generated).
$padded = $key . str_repeat( '=', ( 4 - strlen( $key ) % 4 ) % 4 );
$decoded = base64_decode( strtr( $padded, '-_', '+/' ), true );
$parts = is_string( $decoded ) ? explode( ':', $decoded, 3 ) : array();

if ( 3 === count( $parts ) && is_email( $parts[0] ) && ctype_digit( $parts[1] ) ) {
// New HMAC format: email:timestamp:random.
list( $email, $timestamp, $random ) = $parts;
return array(
'format' => 'new',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let’s avoid terms like “new” and “legacy”, preferring semantically rich terms which can identify the format concretely

perhaps hmac:email:timestamp:random and md5 or v1 and v2 with a description in the docblock

v1 - md5 of random number blah blah (deprecated in 7.1.0)
v2 - hmac of email:timstamp:random

'hash' => base64_encode( hash_hmac( 'sha256', $decoded, wp_salt( 'auth' ), true ) ),
'email' => $email,
'timestamp' => (int) $timestamp,
);
}

if ( 1 === preg_match( '~^[0-9a-f]{16}$~', $key ) ) {
// Legacy plain-text 16-char hex key — stored directly in the DB before 6.9.0.
return array(
'format' => 'legacy',
'hash' => $key,
'email' => null,
'timestamp' => null,
);
}

return null;
}

/**
* Records site signup information for future activation.
*
Expand All @@ -802,7 +892,10 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) {
function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = array() ) {
global $wpdb;

$key = substr( md5( time() . wp_rand() . $domain ), 0, 16 );
$signup_key = _wp_generate_signup_key( $user_email );
$key = $signup_key['key'];
$payload = $signup_key['payload'];
$hash = $signup_key['hash'];

/**
* Filters the metadata for a site signup.
Expand Down Expand Up @@ -830,7 +923,7 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a
'user_login' => $user,
'user_email' => $user_email,
'registered' => current_time( 'mysql', true ),
'activation_key' => $key,
'activation_key' => $hash,
'meta' => serialize( $meta ),
)
);
Expand All @@ -845,10 +938,10 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a
* @param string $title The requested site title.
* @param string $user The user's requested login name.
* @param string $user_email The user's email address.
* @param string $key The user's activation key.
* @param string $payload The URL-safe base64-encoded activation payload sent via email.
* @param array $meta Signup meta data. By default, contains the requested privacy setting and lang_id.
*/
do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta );
do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $payload, $meta );
}

/**
Expand All @@ -871,7 +964,11 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) {
// Format data.
$user = preg_replace( '/\s+/', '', sanitize_user( $user, true ) );
$user_email = sanitize_email( $user_email );
$key = substr( md5( time() . wp_rand() . $user_email ), 0, 16 );

$signup_key = _wp_generate_signup_key( $user_email );
$key = $signup_key['key'];
$payload = $signup_key['payload'];
$hash = $signup_key['hash'];

/**
* Filters the metadata for a user signup.
Expand All @@ -896,7 +993,7 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) {
'user_login' => $user,
'user_email' => $user_email,
'registered' => current_time( 'mysql', true ),
'activation_key' => $key,
'activation_key' => $hash,
'meta' => serialize( $meta ),
)
);
Expand All @@ -908,10 +1005,10 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) {
*
* @param string $user The user's requested login name.
* @param string $user_email The user's email address.
* @param string $key The user's activation key.
* @param string $payload The URL-safe base64-encoded activation payload sent via email.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this warrants a @since tag on the filter noting the change, but it might require some other consideration. what are plugins using this $key value for. in one sense, the change is superficial and this should be an opaque token; on the other hand, previously it was a value stored in the database and now it’s something else.

we might want to continue to return the database-stored value and expose the payload some other way, if that’s what plugin depend on. I did not do a more in-depth analysis; might be a good task for an LLM

* @param array $meta Signup meta data. Default empty array.
*/
do_action( 'after_signup_user', $user, $user_email, $key, $meta );
do_action( 'after_signup_user', $user, $user_email, $payload, $meta );
}

/**
Expand Down Expand Up @@ -1197,12 +1294,50 @@ function wpmu_activate_signup(
) {
global $wpdb;

$signup = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $key ) );
$resolved = _wp_resolve_signup_key( $key );

if ( null === $resolved ) {
return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) );
}

if ( 'new' === $resolved['format'] ) {
$signup = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM $wpdb->signups WHERE user_email = %s AND activation_key = %s",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

with the use of the HMAC, we can remove the constraint on the email as well, right? perhaps this means we could plausibly expect more than one, but as with the signup_id, I think we want to eliminate the variability that we introduce when mixing a secret value (the hash) and a known value (the email address) since that provides an opportunity to expose information about the generation of the hash and gives someone the ability to try different payloads using the same email.

granted, I think given the payload and entropy already in the key this should be a rare problem, but I wonder if further-filtering by email has a benefit, even while it introduces this risk

$resolved['email'],
$resolved['hash']
)
);
} else {
// Legacy plain-text 16-char hex key — allow for BC with pre-upgrade pending activations.
$signup = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM $wpdb->signups WHERE activation_key = %s", $resolved['hash'] )
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a note for here and for line 1283 above with the expiration check.

in the case of the new key format we should have a cryptographically secure way to trust the provided timestamp, however, for legacy keys it looks like we aren’t applying any expiration rules, even though we should have the same $timestamp in the registered column.

in wpmu_validate_user_signup() this value is checked, so maybe it doesn’t matter, but if so, we are potentially leaking information by returning in the one case and not in the other (because the new key expiration will be immediately checked while the legacy key relies on a DB round-trip).

this could be worth keeping out of this ticket, but I would expect to embed the expiration into the database query.

SELECT * FROM $wpdb->signups WHERE activation_key = %s AND registered >= %s

and we would supply the appropriate expiration date in there. (another side note, but it appears that wpmu_validate_user_signup() hard-codes two days for expiration while this code allows extension through 'activate_signup_expiration'

}

if ( empty( $signup ) ) {
return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) );
}

/**
* Filters the expiration time of signup activation keys.
*
* @since 6.9.0
*
* @param int $expiration_duration The expiration time in seconds.
*/
$expiration_duration = apply_filters( 'activate_signup_expiration', DAY_IN_SECONDS );

// For new keys, use the cryptographically bound timestamp from the payload.
// For legacy keys, fall back to the registered column in the database.
$issued_at = 'new' === $resolved['format']
? $resolved['timestamp']
: (int) mysql2date( 'U', $signup->registered );

if ( time() > ( $issued_at + $expiration_duration ) ) {
return new WP_Error( 'expired_key', __( 'Invalid key' ) );
}

if ( $signup->active ) {
if ( empty( $signup->domain ) ) {
return new WP_Error( 'already_active', __( 'The user is already active.' ), $signup );
Expand Down Expand Up @@ -1235,7 +1370,7 @@ function wpmu_activate_signup(
'active' => 1,
'activated' => $now,
),
array( 'activation_key' => $key )
array( 'activation_key' => $signup->activation_key )
);

if ( isset( $user_already_exists ) ) {
Expand Down Expand Up @@ -1277,7 +1412,7 @@ function wpmu_activate_signup(
'active' => 1,
'activated' => $now,
),
array( 'activation_key' => $key )
array( 'activation_key' => $signup->activation_key )
);
}
return $blog_id;
Expand All @@ -1289,7 +1424,7 @@ function wpmu_activate_signup(
'active' => 1,
'activated' => $now,
),
array( 'activation_key' => $key )
array( 'signup_id' => $signup->signup_id )
);

/**
Expand Down
Loading
Loading