-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Security: Hash wp_signups.activation_key (Trac #38474, CVE-2017-14990) #12235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
54c4bb5
b4af97a
6af49cb
9804333
8f5140c
03b9ede
6c93bdd
ed83556
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -785,6 +785,96 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { | |
| return apply_filters( 'wpmu_validate_blog_signup', $result ); | ||
| } | ||
|
|
||
| /** | ||
| * Generates an activation key pair for a multisite signup. | ||
| * | ||
| * Returns a URL-safe base64 payload to embed in the activation link, and a | ||
| * base64-encoded HMAC-SHA256 hash to store in the database. The payload encodes | ||
| * `email:timestamp:random` and the hash is computed over that same string using | ||
| * `AUTH_KEY` + `AUTH_SALT`, so the stored value can be reconstructed from the | ||
| * link without storing the random component separately. | ||
| * | ||
| * The payload uses URL-safe base64 (`-_`, no padding) for embedding in links. | ||
| * The hash uses standard base64 (`+/=`) since it is stored in the DB, not URLs. | ||
| * Both fit within the legacy `activation_key varchar(50)` column constraint. | ||
| * | ||
| * Note: Activation links are invalidated when the site's authentication keys or salts | ||
| * change, consistent with how WordPress invalidates login sessions on salt rotation. | ||
| * | ||
| * @since 6.9.0 | ||
| * @access private | ||
| * | ||
| * @param string $user_email The user's email address, embedded in the payload. | ||
| * @return array { | ||
| * @type string $payload URL-safe base64-encoded activation payload for the email link. | ||
| * @type string $hash Base64-encoded HMAC-SHA256 hash to store in the database. | ||
| * } | ||
| */ | ||
| function _wp_generate_signup_key( $user_email ) { | ||
| $timestamp = time(); | ||
| $random = function_exists( 'random_bytes' ) | ||
| ? bin2hex( random_bytes( 8 ) ) | ||
| : substr( md5( $timestamp . wp_rand() . $user_email ), 0, 16 ); | ||
| $triplet = $user_email . ':' . $timestamp . ':' . $random; | ||
| $payload = rtrim( strtr( base64_encode( $triplet ), '+/', '-_' ), '=' ); | ||
| $hash = base64_encode( hash_hmac( 'sha256', $triplet, wp_salt( 'auth' ), true ) ); | ||
|
|
||
| return array( | ||
| 'key' => $random, | ||
| 'payload' => $payload, | ||
| 'hash' => $hash, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves an activation key from an email link into a database lookup value. | ||
| * | ||
| * Handles both the HMAC-based format introduced in 6.9.0 and legacy plain 16-char | ||
| * hex keys from earlier WordPress versions. | ||
| * | ||
| * @since 6.9.0 | ||
| * @access private | ||
| * | ||
| * @param string $key The activation key from the email link. | ||
| * @return array|null { | ||
| * Parsed key data, or null if the key format is not recognized. | ||
| * | ||
| * @type string $format 'new' for HMAC keys, 'legacy' for plain hex keys. | ||
| * @type string $hash The value to look up in the `activation_key` column. | ||
| * @type string|null $email User email embedded in the key (new format only). | ||
| * @type int|null $timestamp Key issuance timestamp embedded in the key (new format only). | ||
| * } | ||
| */ | ||
| function _wp_resolve_signup_key( $key ) { | ||
| // Restore base64 padding before decoding (it was stripped when the payload was generated). | ||
| $padded = $key . str_repeat( '=', ( 4 - strlen( $key ) % 4 ) % 4 ); | ||
| $decoded = base64_decode( strtr( $padded, '-_', '+/' ), true ); | ||
| $parts = is_string( $decoded ) ? explode( ':', $decoded, 3 ) : array(); | ||
|
|
||
| if ( 3 === count( $parts ) && is_email( $parts[0] ) && ctype_digit( $parts[1] ) ) { | ||
| // New HMAC format: email:timestamp:random. | ||
| list( $email, $timestamp, $random ) = $parts; | ||
| return array( | ||
| 'format' => 'new', | ||
| 'hash' => base64_encode( hash_hmac( 'sha256', $decoded, wp_salt( 'auth' ), true ) ), | ||
| 'email' => $email, | ||
| 'timestamp' => (int) $timestamp, | ||
| ); | ||
| } | ||
|
|
||
| if ( 1 === preg_match( '~^[0-9a-f]{16}$~', $key ) ) { | ||
| // Legacy plain-text 16-char hex key — stored directly in the DB before 6.9.0. | ||
| return array( | ||
| 'format' => 'legacy', | ||
| 'hash' => $key, | ||
| 'email' => null, | ||
| 'timestamp' => null, | ||
| ); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Records site signup information for future activation. | ||
| * | ||
|
|
@@ -802,7 +892,10 @@ function wpmu_validate_blog_signup( $blogname, $blog_title, $user = '' ) { | |
| function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = array() ) { | ||
| global $wpdb; | ||
|
|
||
| $key = substr( md5( time() . wp_rand() . $domain ), 0, 16 ); | ||
| $signup_key = _wp_generate_signup_key( $user_email ); | ||
| $key = $signup_key['key']; | ||
| $payload = $signup_key['payload']; | ||
| $hash = $signup_key['hash']; | ||
|
|
||
| /** | ||
| * Filters the metadata for a site signup. | ||
|
|
@@ -830,7 +923,7 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a | |
| 'user_login' => $user, | ||
| 'user_email' => $user_email, | ||
| 'registered' => current_time( 'mysql', true ), | ||
| 'activation_key' => $key, | ||
| 'activation_key' => $hash, | ||
| 'meta' => serialize( $meta ), | ||
| ) | ||
| ); | ||
|
|
@@ -845,10 +938,10 @@ function wpmu_signup_blog( $domain, $path, $title, $user, $user_email, $meta = a | |
| * @param string $title The requested site title. | ||
| * @param string $user The user's requested login name. | ||
| * @param string $user_email The user's email address. | ||
| * @param string $key The user's activation key. | ||
| * @param string $payload The URL-safe base64-encoded activation payload sent via email. | ||
| * @param array $meta Signup meta data. By default, contains the requested privacy setting and lang_id. | ||
| */ | ||
| do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $key, $meta ); | ||
| do_action( 'after_signup_site', $domain, $path, $title, $user, $user_email, $payload, $meta ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -871,7 +964,11 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { | |
| // Format data. | ||
| $user = preg_replace( '/\s+/', '', sanitize_user( $user, true ) ); | ||
| $user_email = sanitize_email( $user_email ); | ||
| $key = substr( md5( time() . wp_rand() . $user_email ), 0, 16 ); | ||
|
|
||
| $signup_key = _wp_generate_signup_key( $user_email ); | ||
| $key = $signup_key['key']; | ||
| $payload = $signup_key['payload']; | ||
| $hash = $signup_key['hash']; | ||
|
|
||
| /** | ||
| * Filters the metadata for a user signup. | ||
|
|
@@ -896,7 +993,7 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { | |
| 'user_login' => $user, | ||
| 'user_email' => $user_email, | ||
| 'registered' => current_time( 'mysql', true ), | ||
| 'activation_key' => $key, | ||
| 'activation_key' => $hash, | ||
| 'meta' => serialize( $meta ), | ||
| ) | ||
| ); | ||
|
|
@@ -908,10 +1005,10 @@ function wpmu_signup_user( $user, $user_email, $meta = array() ) { | |
| * | ||
| * @param string $user The user's requested login name. | ||
| * @param string $user_email The user's email address. | ||
| * @param string $key The user's activation key. | ||
| * @param string $payload The URL-safe base64-encoded activation payload sent via email. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this warrants a 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 ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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'] ) | ||
| ); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 in 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 >= %sand we would supply the appropriate expiration date in there. (another side note, but it appears that |
||
| } | ||
|
|
||
| if ( empty( $signup ) ) { | ||
| return new WP_Error( 'invalid_key', __( 'Invalid activation key.' ) ); | ||
| } | ||
|
|
||
| /** | ||
| * Filters the expiration time of signup activation keys. | ||
| * | ||
| * @since 6.9.0 | ||
| * | ||
| * @param int $expiration_duration The expiration time in seconds. | ||
| */ | ||
| $expiration_duration = apply_filters( 'activate_signup_expiration', DAY_IN_SECONDS ); | ||
|
|
||
| // For new keys, use the cryptographically bound timestamp from the payload. | ||
| // For legacy keys, fall back to the registered column in the database. | ||
| $issued_at = 'new' === $resolved['format'] | ||
| ? $resolved['timestamp'] | ||
| : (int) mysql2date( 'U', $signup->registered ); | ||
|
|
||
| if ( time() > ( $issued_at + $expiration_duration ) ) { | ||
| return new WP_Error( 'expired_key', __( 'Invalid key' ) ); | ||
| } | ||
|
|
||
| if ( $signup->active ) { | ||
| if ( empty( $signup->domain ) ) { | ||
| return new WP_Error( 'already_active', __( 'The user is already active.' ), $signup ); | ||
|
|
@@ -1235,7 +1370,7 @@ function wpmu_activate_signup( | |
| 'active' => 1, | ||
| 'activated' => $now, | ||
| ), | ||
| array( 'activation_key' => $key ) | ||
| array( 'activation_key' => $signup->activation_key ) | ||
| ); | ||
|
|
||
| if ( isset( $user_already_exists ) ) { | ||
|
|
@@ -1277,7 +1412,7 @@ function wpmu_activate_signup( | |
| 'active' => 1, | ||
| 'activated' => $now, | ||
| ), | ||
| array( 'activation_key' => $key ) | ||
| array( 'activation_key' => $signup->activation_key ) | ||
| ); | ||
| } | ||
| return $blog_id; | ||
|
|
@@ -1289,7 +1424,7 @@ function wpmu_activate_signup( | |
| 'active' => 1, | ||
| 'activated' => $now, | ||
| ), | ||
| array( 'activation_key' => $key ) | ||
| array( 'signup_id' => $signup->signup_id ) | ||
| ); | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
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:randomandmd5orv1andv2with a description in the docblockv1 - md5 of random number blah blah (deprecated in 7.1.0)v2 - hmac of email:timstamp:random