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;
+ }
+ }
}