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