Commit e650d35a541 for woocommerce

commit e650d35a5415648d191732701ee528a494b857ce
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Oct 7 13:17:35 2026 +0200

    Update REST API key nonce handling (#69523)

    Co-authored-by: Raluca Stan <1628454+ralucaStan@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-rest-api-nonce-storage b/plugins/woocommerce/changelog/fix-rest-api-nonce-storage
new file mode 100644
index 00000000000..f5aeb75b1e0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-rest-api-nonce-storage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update REST API key nonce handling.
diff --git a/plugins/woocommerce/includes/class-wc-rest-authentication.php b/plugins/woocommerce/includes/class-wc-rest-authentication.php
index 4eb2e91027f..768bc221d20 100644
--- a/plugins/woocommerce/includes/class-wc-rest-authentication.php
+++ b/plugins/woocommerce/includes/class-wc-rest-authentication.php
@@ -684,19 +684,26 @@ class WC_REST_Authentication {

 		$used_nonces = maybe_unserialize( $user->nonces );

-		if ( empty( $used_nonces ) ) {
+		if ( empty( $used_nonces ) || ! is_array( $used_nonces ) ) {
 			$used_nonces = array();
 		}

-		if ( in_array( $nonce, $used_nonces, true ) ) {
-			return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'woocommerce' ), array( 'status' => 401 ) );
+		foreach ( $used_nonces as $timestamp_nonces ) {
+			if ( in_array( $nonce, (array) $timestamp_nonces, true ) ) {
+				return new WP_Error( 'woocommerce_rest_authentication_error', __( 'Invalid nonce - nonce has already been used.', 'woocommerce' ), array( 'status' => 401 ) );
+			}
 		}

-		$used_nonces[ $timestamp ] = $nonce;
+		if ( isset( $used_nonces[ $timestamp ] ) ) {
+			$used_nonces[ $timestamp ]   = (array) $used_nonces[ $timestamp ];
+			$used_nonces[ $timestamp ][] = $nonce;
+		} else {
+			$used_nonces[ $timestamp ] = $nonce;
+		}

 		// Remove expired nonces.
-		foreach ( $used_nonces as $nonce_timestamp => $nonce ) {
-			if ( $nonce_timestamp < ( time() - $valid_window ) ) {
+		foreach ( array_keys( $used_nonces ) as $nonce_timestamp ) {
+			if ( ! is_numeric( $nonce_timestamp ) || $nonce_timestamp < ( time() - $valid_window ) ) {
 				unset( $used_nonces[ $nonce_timestamp ] );
 			}
 		}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
index e78534ef91f..3b290be3a7d 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-authentication-tests.php
@@ -732,4 +732,208 @@ class WC_REST_Authentication_Tests extends WC_REST_Unit_Test_Case {
 		$update_last_access->setAccessible( false );
 		add_filter( 'woocommerce_disable_rest_api_access_log', $last_access_spy );
 	}
+
+	/**
+	 * @testdox Should accept two distinct nonces
+	 */
+	public function test_check_oauth_timestamp_and_nonce_accepts_distinct_nonces_at_same_timestamp(): void {
+		global $wpdb;
+
+		$key_id = $this->insert_nonce_test_key();
+
+		try {
+			$timestamp = time();
+
+			$first = $this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'nonce-a' );
+			$this->assertTrue( $first, 'The first request should be accepted.' );
+
+			$second = $this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'nonce-b' );
+			$this->assertTrue( $second, 'A second, distinct nonce must also be accepted.' );
+		} finally {
+			$wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => $key_id ) );
+		}
+	}
+
+	/**
+	 * @testdox Should reject a nonce that was already used.
+	 */
+	public function test_check_oauth_timestamp_and_nonce_rejects_reused_nonce(): void {
+		global $wpdb;
+
+		$key_id = $this->insert_nonce_test_key();
+
+		try {
+			$timestamp = time();
+
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'nonce-a' );
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'nonce-b' );
+
+			$repeat = $this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'nonce-a' );
+
+			$this->assertWPError( $repeat, 'A nonce already recorded must be rejected on reuse.' );
+			$this->assertSame( 'woocommerce_rest_authentication_error', $repeat->get_error_code() );
+		} finally {
+			$wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => $key_id ) );
+		}
+	}
+
+	/**
+	 * @testdox Should purge nonces older than the 15-minute validity window instead of retaining them indefinitely.
+	 */
+	public function test_check_oauth_timestamp_and_nonce_purges_expired_nonces(): void {
+		global $wpdb;
+
+		$key_id = $this->insert_nonce_test_key();
+
+		try {
+			$expired_timestamp = time() - ( 16 * MINUTE_IN_SECONDS );
+
+			$wpdb->update(
+				$wpdb->prefix . 'woocommerce_api_keys',
+				array( 'nonces' => maybe_serialize( array( $expired_timestamp => 'expired-nonce' ) ) ),
+				array( 'key_id' => $key_id )
+			);
+
+			$timestamp = time();
+
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'fresh-nonce' );
+
+			$stored_nonces = maybe_unserialize( $this->fetch_nonce_test_user( $key_id )->nonces );
+
+			$this->assertSame( array( $timestamp => 'fresh-nonce' ), $stored_nonces, 'Only the nonce from the current request must remain.' );
+		} finally {
+			$wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => $key_id ) );
+		}
+	}
+
+	/**
+	 * @testdox Should reject a nonce that a previous version stored, and keep that entry.
+	 */
+	public function test_check_oauth_timestamp_and_nonce_rejects_previously_stored_nonce(): void {
+		global $wpdb;
+
+		$key_id = $this->insert_nonce_test_key();
+
+		try {
+			$stored_timestamp = time() - MINUTE_IN_SECONDS;
+
+			$wpdb->update(
+				$wpdb->prefix . 'woocommerce_api_keys',
+				array( 'nonces' => maybe_serialize( array( $stored_timestamp => 'stored-nonce' ) ) ),
+				array( 'key_id' => $key_id )
+			);
+
+			$repeat = $this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $stored_timestamp, 'stored-nonce' );
+			$this->assertWPError( $repeat, 'A nonce stored before the update must still be rejected on reuse.' );
+
+			$timestamp = time();
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $timestamp, 'fresh-nonce' );
+
+			$stored_nonces = maybe_unserialize( $this->fetch_nonce_test_user( $key_id )->nonces );
+
+			$this->assertSame(
+				array(
+					$stored_timestamp => 'stored-nonce',
+					$timestamp        => 'fresh-nonce',
+				),
+				$stored_nonces,
+				'A still-valid stored nonce must survive a later request.'
+			);
+		} finally {
+			$wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => $key_id ) );
+		}
+	}
+
+	/**
+	 * @testdox Should store a single nonce as a string and nonces sharing a timestamp as a list.
+	 */
+	public function test_check_oauth_timestamp_and_nonce_stored_shape(): void {
+		global $wpdb;
+
+		$key_id = $this->insert_nonce_test_key();
+
+		try {
+			$shared_timestamp = time() - 1;
+			$single_timestamp = time();
+
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $shared_timestamp, 'nonce-a' );
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $shared_timestamp, 'nonce-b' );
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $shared_timestamp, 'nonce-c' );
+			$this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $single_timestamp, 'nonce-d' );
+
+			$stored_nonces = maybe_unserialize( $this->fetch_nonce_test_user( $key_id )->nonces );
+
+			$this->assertSame(
+				array(
+					$shared_timestamp => array( 'nonce-a', 'nonce-b', 'nonce-c' ),
+					$single_timestamp => 'nonce-d',
+				),
+				$stored_nonces,
+				'A lone nonce must keep the plain string shape; only a shared timestamp becomes a list.'
+			);
+
+			$repeat = $this->check_oauth_timestamp_and_nonce( $this->fetch_nonce_test_user( $key_id ), $shared_timestamp, 'nonce-b' );
+			$this->assertWPError( $repeat, 'A nonce held in a list must be rejected on reuse.' );
+		} finally {
+			$wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'key_id' => $key_id ) );
+		}
+	}
+
+	/**
+	 * Insert a real API key row for the nonce tests and return its key_id.
+	 *
+	 * @return int
+	 */
+	private function insert_nonce_test_key(): int {
+		global $wpdb;
+
+		$consumer_key = 'ck_' . wp_generate_password( 32, false );
+
+		$wpdb->insert(
+			$wpdb->prefix . 'woocommerce_api_keys',
+			array(
+				'user_id'         => 1,
+				'description'     => 'Nonce test key',
+				'permissions'     => 'read_write',
+				'consumer_key'    => wc_api_hash( $consumer_key ),
+				'consumer_secret' => 'cs_' . wp_generate_password( 32, false ),
+				'truncated_key'   => substr( $consumer_key, -7 ),
+			)
+		);
+
+		return (int) $wpdb->insert_id;
+	}
+
+	/**
+	 * Fetch a fresh copy of the user data check_oauth_timestamp_and_nonce() expects, the way a live request
+	 * would after WC_REST_Authentication re-reads the key row from the database.
+	 *
+	 * @param int $key_id API key row to fetch.
+	 * @return object
+	 */
+	private function fetch_nonce_test_user( int $key_id ): object {
+		global $wpdb;
+
+		return $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces FROM {$wpdb->prefix}woocommerce_api_keys WHERE key_id = %d",
+				$key_id
+			)
+		);
+	}
+
+	/**
+	 * Call the private check_oauth_timestamp_and_nonce() for the given user/timestamp/nonce.
+	 *
+	 * @param object $user      User data, as returned by fetch_nonce_test_user().
+	 * @param int    $timestamp OAuth timestamp.
+	 * @param string $nonce     OAuth nonce.
+	 * @return bool|WP_Error
+	 */
+	private function check_oauth_timestamp_and_nonce( object $user, int $timestamp, string $nonce ) {
+		$method = new ReflectionMethod( $this->sut, 'check_oauth_timestamp_and_nonce' );
+		$method->setAccessible( true );
+
+		return $method->invoke( $this->sut, $user, $timestamp, $nonce );
+	}
 }