Commit 4a08c79b14e for woocommerce

commit 4a08c79b14e5ee69ea7f05074fe084cae64a0c3c
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Wed Aug 5 12:41:49 2026 +0200

    Update session cookies hashing (#67333)

    * Update session cookies hashing

    Compute the session cookie integrity tag with a secret-keyed HMAC
    (wp_hash) instead of the keyless wp_fast_hash.

    * Accept legacy wp_fast_hash session cookies

    Fall back to wp_verify_fast_hash() when the new tag does not match, so guest
    sessions created before this change are not invalidated on deploy. The fallback
    only runs for hashes carrying wp_fast_hash()'s $generic$ prefix and can be
    removed once those cookies have expired.

    Adds regression tests covering the current tag, the legacy tag, and tampered
    hashes/customer IDs.

    * Apply suggestion from @senadir

    ---------

    Co-authored-by: Seghir Nadir <nadir.seghir@a8c.com>

diff --git a/plugins/woocommerce/changelog/switch-from-wp-fast-hash-to-hash b/plugins/woocommerce/changelog/switch-from-wp-fast-hash-to-hash
new file mode 100644
index 00000000000..74148301263
--- /dev/null
+++ b/plugins/woocommerce/changelog/switch-from-wp-fast-hash-to-hash
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update session cookies hashing
diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php
index d732bcff540..5e18d2168eb 100644
--- a/plugins/woocommerce/includes/class-wc-session-handler.php
+++ b/plugins/woocommerce/includes/class-wc-session-handler.php
@@ -308,34 +308,36 @@ class WC_Session_Handler extends WC_Session {
 	}

 	/**
-	 * Hash a value using wp_fast_hash (from WP 6.8 onwards).
-	 *
-	 * This method can be removed when the minimum version supported is 6.8.
+	 * Hash a value for the session cookie integrity tag.
 	 *
 	 * @param string $message Value to hash.
 	 * @return string Hashed value.
 	 */
 	private function hash( string $message ) {
-		if ( function_exists( 'wp_fast_hash' ) ) {
-			return wp_fast_hash( $message );
-		}
 		return hash_hmac( 'md5', $message, wp_hash( $message ) );
 	}

 	/**
-	 * Verify a hash using wp_verify_fast_hash (from WP 6.8 onwards).
+	 * Verify a hash produced by self::hash().
 	 *
-	 * This method can be removed when the minimum version supported is 6.8.
+	 * Hashes produced by the previous `wp_fast_hash()` implementation are still accepted so that guest sessions
+	 * created before this change are not invalidated. That fallback can be removed in 11.1.0 forward after those cookies have expired.
 	 *
 	 * @param string $message Message to verify.
 	 * @param string $hash Hash to verify.
 	 * @return bool Whether the hash is valid.
 	 */
 	private function verify_hash( string $message, string $hash ) {
-		if ( function_exists( 'wp_verify_fast_hash' ) ) {
+		if ( hash_equals( $this->hash( $message ), $hash ) ) {
+			return true;
+		}
+
+		// `wp_fast_hash()` prefixes its output with `$generic$`, so only those cookies take the legacy path.
+		if ( function_exists( 'wp_verify_fast_hash' ) && str_starts_with( $hash, '$generic$' ) ) {
 			return wp_verify_fast_hash( $message, $hash );
 		}
-		return hash_equals( hash_hmac( 'md5', $message, wp_hash( $message ) ), $hash );
+
+		return false;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php b/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
index 638f75862b6..d9a7749a3b5 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/session/class-wc-tests-session-handler.php
@@ -41,6 +41,8 @@ class WC_Tests_Session_Handler extends WC_Unit_Test_Case {
 		$features            = $features_controller->get_features( true );
 		$features_controller->change_feature_enable( self::DESTROY_EMPTY_SESSION_FEATURE, ! empty( $features[ self::DESTROY_EMPTY_SESSION_FEATURE ]['enabled_by_default'] ) );

+		unset( $_COOKIE[ $this->get_session_cookie_name() ] );
+
 		parent::tearDown();
 	}

@@ -515,6 +517,90 @@ class WC_Tests_Session_Handler extends WC_Unit_Test_Case {
 		$this->assertSame( array( array( 'customer' ) ), $wpdb->get_results( $wpdb->prepare( "SELECT session_key FROM %i WHERE session_key IN ('guest', 'customer')", "{$wpdb->prefix}woocommerce_sessions" ), ARRAY_N ) );
 	}

+	/**
+	 * @testdox Test that get_session_cookie accepts a cookie hashed with the current implementation.
+	 */
+	public function test_get_session_cookie_accepts_current_hash(): void {
+		$this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'current' ) );
+
+		$cookie = $this->handler->get_session_cookie();
+
+		$this->assertNotFalse( $cookie, 'Cookie hashed with the current implementation should be accepted.' );
+		$this->assertSame( 'cust_1', $cookie[0] );
+	}
+
+	/**
+	 * @testdox Test that get_session_cookie still accepts a cookie hashed with the legacy wp_fast_hash implementation.
+	 */
+	public function test_get_session_cookie_accepts_legacy_fast_hash(): void {
+		if ( ! function_exists( 'wp_fast_hash' ) ) {
+			$this->markTestSkipped( 'wp_fast_hash() requires WordPress 6.8 or newer.' );
+		}
+
+		$this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'legacy' ) );
+
+		$cookie = $this->handler->get_session_cookie();
+
+		$this->assertNotFalse( $cookie, 'Cookie hashed with wp_fast_hash() should still be accepted so existing guest sessions survive.' );
+		$this->assertSame( 'cust_1', $cookie[0] );
+	}
+
+	/**
+	 * @testdox Test that get_session_cookie rejects a cookie with a tampered hash.
+	 */
+	public function test_get_session_cookie_rejects_tampered_hash(): void {
+		$this->set_session_cookie( $this->build_session_cookie_value( 'cust_1', 'current' ) . 'tampered' );
+
+		$this->assertFalse( $this->handler->get_session_cookie() );
+	}
+
+	/**
+	 * @testdox Test that get_session_cookie rejects a cookie whose customer ID no longer matches the hash.
+	 */
+	public function test_get_session_cookie_rejects_tampered_customer_id(): void {
+		$cookie_value = $this->build_session_cookie_value( 'cust_1', 'current' );
+		$this->set_session_cookie( str_replace( 'cust_1', 'cust_2', $cookie_value ) );
+
+		$this->assertFalse( $this->handler->get_session_cookie() );
+	}
+
+	/**
+	 * Helper function to build a session cookie value for the handler under test.
+	 *
+	 * @param string $customer_id Customer ID to embed in the cookie.
+	 * @param string $hash_type   Either 'current' for the wp_hash() based tag, or 'legacy' for a wp_fast_hash() tag.
+	 * @return string
+	 */
+	protected function build_session_cookie_value( string $customer_id, string $hash_type ): string {
+		$session_expiration = time() + DAY_IN_SECONDS;
+		$session_expiring   = $session_expiration - HOUR_IN_SECONDS;
+		$message            = $customer_id . '|' . $session_expiration;
+		$cookie_hash        = 'legacy' === $hash_type ? wp_fast_hash( $message ) : hash_hmac( 'md5', $message, wp_hash( $message ) );
+
+		return implode( '|', array( $customer_id, $session_expiration, $session_expiring, $cookie_hash ) );
+	}
+
+	/**
+	 * Helper function to set the session cookie as if it were passed by the browser.
+	 *
+	 * @param string $cookie_value Raw cookie value.
+	 */
+	protected function set_session_cookie( string $cookie_value ) {
+		$_COOKIE[ $this->get_session_cookie_name() ] = $cookie_value;
+	}
+
+	/**
+	 * Helper function to read the cookie name used by the handler under test.
+	 *
+	 * @return string
+	 */
+	protected function get_session_cookie_name(): string {
+		$cookie_property = ( new ReflectionClass( $this->handler ) )->getProperty( '_cookie' );
+		$cookie_property->setAccessible( true );
+
+		return (string) $cookie_property->getValue( $this->handler );
+	}
+
 	/**
 	 * Helper function to create a WC session and save it to the DB.
 	 */