Commit e2d7c0f676d for woocommerce
commit e2d7c0f676d37125803192260fb8b42993ccd897
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Wed Sep 30 11:31:51 2026 +0100
Require expected email when verifying user (#68787)
* mark_verified requires expected email
* Check user
* Remove test
* Update expected email
* Update changelog
diff --git a/plugins/woocommerce/changelog/fix-customer-email-verification-proof-binding b/plugins/woocommerce/changelog/fix-customer-email-verification-proof-binding
new file mode 100644
index 00000000000..e38dce854c0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-customer-email-verification-proof-binding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure customer email verification and guest order linking use the address confirmed during a password reset or key confirmation.
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php
index 4518fb925a2..49f584dcf29 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Admin/UserProfileField.php
@@ -102,7 +102,8 @@ class UserProfileField {
}
if ( isset( $_POST[ self::FIELD ] ) ) {
- $this->service->mark_verified( $user_id );
+ $user = get_user_by( 'id', $user_id );
+ $this->service->mark_verified( $user_id, $user instanceof WP_User ? $user->user_email : null );
} else {
$this->service->clear_verification( $user_id );
}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
index 39cb8bc8d31..b28c88b0c07 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
@@ -80,25 +80,28 @@ class EmailVerificationService {
}
/**
- * Mark the given user as having verified their current account email address.
+ * Mark the given user as having verified the expected account email address.
*
* Stores the verified email address, clears any pending key, and fires the
* {@see 'woocommerce_customer_email_verified'} action. No-ops if the user is already
- * verified for their current email.
+ * verified for their current email. The expected email must still match the account email,
+ * preventing a concurrently changed address from being marked as verified.
*
* @since 11.0.0
*
- * @param int $user_id WordPress user ID.
+ * @param int $user_id WordPress user ID.
+ * @param string|null $expected_email Email address proven by the verification flow.
* @return void
*/
- public function mark_verified( int $user_id ): void {
+ public function mark_verified( int $user_id, ?string $expected_email ): void {
if ( $this->is_verified( $user_id ) ) {
return;
}
- $account_email = $this->get_account_email( $user_id );
+ $account_email = $this->get_account_email( $user_id );
+ $expected_email = null !== $expected_email ? strtolower( $expected_email ) : null;
- if ( null === $account_email ) {
+ if ( null === $expected_email || null === $account_email || $expected_email !== $account_email ) {
return;
}
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
index 71f7849723a..309a44929b2 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
@@ -357,11 +357,16 @@ class VerificationController {
if ( ! $user_id || '' === $key ) {
return false;
}
+ $user = get_user_by( 'id', $user_id );
+ if ( ! $user instanceof \WP_User ) {
+ return false;
+ }
+ $expected_email = $user->user_email;
if ( ! $this->service->check_verification_key( $user_id, $key ) ) {
return false;
}
- $this->service->mark_verified( $user_id );
- return true;
+ $this->service->mark_verified( $user_id, $expected_email );
+ return $this->service->is_verified( $user_id );
}
/**
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php
index 021f76030d3..fd3df400162 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationEventListener.php
@@ -52,7 +52,7 @@ class VerificationEventListener {
*/
public function on_password_reset( $user ): void {
if ( $user instanceof WP_User ) {
- $this->service->mark_verified( $user->ID );
+ $this->service->mark_verified( $user->ID, $user->user_email );
}
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php
index b2ec6aa23cd..0bb9ff441de 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Admin/UserProfileFieldTest.php
@@ -66,7 +66,7 @@ class UserProfileFieldTest extends WC_Unit_Test_Case {
*/
public function test_save_clears_when_unchecked(): void {
$user_id = wc_create_new_customer( 'profile-uncheck@example.com', 'profileuncheck', 'pw' );
- $this->service->mark_verified( $user_id );
+ $this->service->mark_verified( $user_id, 'profile-uncheck@example.com' );
$this->assertTrue( $this->service->is_verified( $user_id ) );
$_POST['wc_email_verified_nonce'] = wp_create_nonce( 'wc_email_verified_' . $user_id );
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
index acd61ac0777..19cf3bc1e88 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
@@ -52,7 +52,7 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
};
add_action( 'woocommerce_customer_email_verified', $listener );
- $this->sut->mark_verified( $user_id );
+ $this->sut->mark_verified( $user_id, 'b@example.com' );
$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified after mark_verified()' );
$this->assertSame( 1, $hook_calls, 'Hook should fire exactly once' );
@@ -93,7 +93,7 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
$user_id = wc_create_new_customer( 'single@example.com', 'singleuser', 'pw' );
$key = $this->sut->create_verification_key( $user_id );
- $this->sut->mark_verified( $user_id );
+ $this->sut->mark_verified( $user_id, 'single@example.com' );
$this->assertFalse( $this->sut->has_pending_key( $user_id ), 'Verifying should consume the pending key' );
$this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'A consumed key must not re-validate' );
@@ -154,13 +154,43 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
$this->assertFalse( $this->sut->is_verified( $user_id ) );
}
+ /**
+ * @testdox mark_verified() does not verify an account email that differs from the proven email.
+ */
+ public function test_mark_verified_rejects_changed_email(): void {
+ $user_id = wc_create_new_customer( 'expected@example.com', 'expecteduser', 'pw' );
+ $key = $this->sut->create_verification_key( $user_id );
+ $hook_calls = 0;
+ $listener = static function () use ( &$hook_calls ) {
+ ++$hook_calls;
+ };
+ add_action( 'woocommerce_customer_email_verified', $listener );
+
+ wp_update_user(
+ array(
+ 'ID' => $user_id,
+ 'user_email' => 'changed@example.com',
+ )
+ );
+ clean_user_cache( $user_id );
+
+ $this->sut->mark_verified( $user_id, 'expected@example.com' );
+
+ $this->assertFalse( $this->sut->is_verified( $user_id ), 'A different current account email must not be marked as verified' );
+ $this->assertSame( 0, $hook_calls, 'A mismatched email must not fire the verification hook' );
+ $this->assertTrue( $this->sut->has_pending_key( $user_id ), 'A mismatched email must not consume the pending key' );
+ $this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'The key must remain bound to the original email' );
+
+ remove_action( 'woocommerce_customer_email_verified', $listener );
+ }
+
/**
* @testdox Clearing verification should reset the user's verified status.
*/
public function test_clear_verification_resets_status(): void {
$user_id = wc_create_new_customer( 'e@example.com', 'usere', 'pw' );
- $this->sut->mark_verified( $user_id );
+ $this->sut->mark_verified( $user_id, 'e@example.com' );
$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified before clearing' );
$this->sut->clear_verification( $user_id );
@@ -174,7 +204,7 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
public function test_is_verified_false_after_email_change(): void {
$user_id = wc_create_new_customer( 'before-change@example.com', 'changeuser', 'pw' );
- $this->sut->mark_verified( $user_id );
+ $this->sut->mark_verified( $user_id, 'before-change@example.com' );
$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified for their current email' );
wp_update_user(
@@ -194,7 +224,7 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
public function test_is_verified_preserved_after_non_email_change(): void {
$user_id = wc_create_new_customer( 'keep-verified@example.com', 'keepuser', 'pw' );
- $this->sut->mark_verified( $user_id );
+ $this->sut->mark_verified( $user_id, 'keep-verified@example.com' );
wp_update_user(
array(
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
index 537a74847d3..603a1ba9c70 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
@@ -201,7 +201,7 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
public function test_should_show_prompt_returns_false_for_verified_customer(): void {
$user_id = wc_create_new_customer( 'prompt-verified@example.com', 'promptverified', 'pw' );
wp_set_current_user( $user_id );
- $this->service->mark_verified( $user_id );
+ $this->service->mark_verified( $user_id, 'prompt-verified@example.com' );
$this->assertFalse( $this->sut->should_show_prompt(), 'Verified customers should not see the prompt' );
}
@@ -212,7 +212,7 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
public function test_should_show_prompt_ignores_filter_for_verified_customer(): void {
$user_id = wc_create_new_customer( 'prompt-verified-filtered@example.com', 'promptverifiedfiltered', 'pw' );
wp_set_current_user( $user_id );
- $this->service->mark_verified( $user_id );
+ $this->service->mark_verified( $user_id, 'prompt-verified-filtered@example.com' );
add_filter( 'woocommerce_customer_email_verification_should_show_prompt', '__return_true' );
$this->assertFalse( $this->sut->should_show_prompt(), 'A verified customer should never see the prompt, even if a filter tries to force it on.' );