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.
*/