Commit c9c697698a for woocommerce

commit c9c697698a6bae27f35cf67e9acf282496a27af2
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Mon Dec 8 17:12:43 2025 +0100

    Only rate limit POST endpoint in Store API (#62076)

    * Only rate limit POST endpoint in Store API

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Refactor is_only_post_request method documentation and add unit test for accurate POST request validation

    * fix lint

    * Improve test file:

    - Improve failure message
    - Ensure $_SERVER cleanup even on failure with try-finally block
    - Expanded test coverage with additional HTTP method scenarios

    * Update rate limiting doc

    * Handle empty string method override

    ---------

    Co-authored-by: github-actions <github-actions@github.com>
    Co-authored-by: Raluca Stan <ralucastn@gmail.com>

diff --git a/docs/apis/store-api/rate-limiting.md b/docs/apis/store-api/rate-limiting.md
index c0c645655f..c6c2a2090f 100644
--- a/docs/apis/store-api/rate-limiting.md
+++ b/docs/apis/store-api/rate-limiting.md
@@ -29,7 +29,7 @@ A default maximum of 25 requests can be made within a 10-second time frame. Thes

 ## Methods restricted by Rate Limiting

-`POST`, `PUT`, `PATCH`, and `DELETE`
+Only `POST` requests are rate limited. Requests using the `X-HTTP-Method-Override` header (such as `PUT`, `PATCH` requests sent via `wp.apiFetch`) are excluded from rate limiting.

 ## Rate Limiting options filter

diff --git a/plugins/woocommerce/changelog/62076-fix-apply-rate-limit b/plugins/woocommerce/changelog/62076-fix-apply-rate-limit
new file mode 100644
index 0000000000..605dd44f62
--- /dev/null
+++ b/plugins/woocommerce/changelog/62076-fix-apply-rate-limit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix issue in which regular Checkout requests were getting counted toward rate limit if rate limiting is enabled.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/StoreApi/Authentication.php b/plugins/woocommerce/src/StoreApi/Authentication.php
index a3c352e2eb..1204650a8d 100644
--- a/plugins/woocommerce/src/StoreApi/Authentication.php
+++ b/plugins/woocommerce/src/StoreApi/Authentication.php
@@ -155,8 +155,7 @@ class Authentication {
 			FeaturesUtil::feature_is_enabled( 'rate_limit_checkout' )
 			&& $this->is_request_to_store_api()
 			&& preg_match( '#/wc/store(?:/v\d+)?/checkout#', $GLOBALS['wp']->query_vars['rest_route'] )
-			&& isset( $_SERVER['REQUEST_METHOD'] )
-			&& 'POST' === $_SERVER['REQUEST_METHOD']
+			&& $this->is_only_post_request()
 		) {
 			add_filter(
 				'woocommerce_store_api_rate_limit_options',
@@ -265,6 +264,31 @@ class Authentication {
 		return 0 === strpos( $GLOBALS['wp']->query_vars['rest_route'], '/wc/store/' );
 	}

+	/**
+	 * Returns true only for POST requests that are NOT overridden to another method
+	 * via the X-HTTP-Method-Override header (used by wp.apiFetch for PUT/DELETE).
+	 *
+	 * @see https://github.com/wordpress/gutenberg/blob/trunk/packages/api-fetch/src/middlewares/http-v1.ts#L21-L43
+	 *
+	 * @return bool
+	 */
+	private function is_only_post_request() {
+		// Check that REQUEST_METHOD is POST.
+		if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) {
+			return false;
+		}
+
+		// Check X-HTTP-Method-Override header if it exists and is not empty - it must also be POST.
+		if ( isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) {
+			$method_override = strtoupper( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) );
+			if ( '' !== $method_override && 'POST' !== $method_override ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Get current user IP Address.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php
index 349a600dfa..c1bd611929 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/RateLimitsTests.php
@@ -174,4 +174,114 @@ class RateLimitsTests extends WP_Test_REST_TestCase {
 			$get_rate_limiting_id->invokeArgs( $authentication, array( false ) )
 		);
 	}
+
+	/**
+	 * Provides test cases for is_only_post_request() method.
+	 *
+	 * @return array[] Test cases with REQUEST_METHOD, override header, and expected result.
+	 */
+	public function provide_is_only_post_request_test_cases(): array {
+		return array(
+			'pure POST request without override header'    => array(
+				'method'   => 'POST',
+				'override' => null,
+				'expected' => true,
+			),
+			'POST request overridden to PUT via header'    => array(
+				'method'   => 'POST',
+				'override' => 'PUT',
+				'expected' => false,
+			),
+			'POST request overridden to DELETE via header' => array(
+				'method'   => 'POST',
+				'override' => 'DELETE',
+				'expected' => false,
+			),
+			'POST request with POST override header (redundant)' => array(
+				'method'   => 'POST',
+				'override' => 'POST',
+				'expected' => true,
+			),
+			'POST request with empty override header'      => array(
+				'method'   => 'POST',
+				'override' => '',
+				'expected' => true,
+			),
+			'GET request without override header'          => array(
+				'method'   => 'GET',
+				'override' => null,
+				'expected' => false,
+			),
+			'GET request with POST override - method precedence' => array(
+				'method'   => 'GET',
+				'override' => 'POST',
+				'expected' => false,
+			),
+			'PUT request without override header'          => array(
+				'method'   => 'PUT',
+				'override' => null,
+				'expected' => false,
+			),
+			'DELETE request without override header'       => array(
+				'method'   => 'DELETE',
+				'override' => null,
+				'expected' => false,
+			),
+			'PATCH request without override header'        => array(
+				'method'   => 'PATCH',
+				'override' => null,
+				'expected' => false,
+			),
+			'POST request overridden to PATCH via header'  => array(
+				'method'   => 'POST',
+				'override' => 'PATCH',
+				'expected' => false,
+			),
+		);
+	}
+
+	/**
+	 * Tests that is_only_post_request() correctly identifies true POST requests
+	 * and rejects requests with X-HTTP-Method-Override header set to another method.
+	 *
+	 * @dataProvider provide_is_only_post_request_test_cases
+	 *
+	 * @param string      $method   The REQUEST_METHOD value.
+	 * @param string|null $override The X-HTTP-Method-Override header value, or null if unset.
+	 * @param bool        $expected The expected return value.
+	 *
+	 * @return void
+	 * @throws ReflectionException On failing invoked private method through reflection class.
+	 */
+	public function test_is_only_post_request_method( string $method, ?string $override, bool $expected ) {
+		$original_server = $_SERVER;
+
+		try {
+			$authentication          = new ReflectionClass( Authentication::class );
+			$is_only_post_request    = $authentication->getMethod( 'is_only_post_request' );
+			$authentication_instance = $authentication->newInstance();
+			$is_only_post_request->setAccessible( true );
+
+			$_SERVER['REQUEST_METHOD'] = $method;
+
+			if ( null === $override ) {
+				unset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] );
+			} else {
+				$_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = $override;
+			}
+
+			$this->assertSame(
+				$expected,
+				$is_only_post_request->invoke( $authentication_instance ),
+				sprintf(
+					'is_only_post_request() should return %s for REQUEST_METHOD=%s with override=%s',
+					$expected ? 'true' : 'false',
+					$method,
+					$override ?? 'null'
+				)
+			);
+		} finally {
+			$_SERVER = $original_server;
+		}
+	}
 }