Commit dea35d2e30f for woocommerce
commit dea35d2e30f736fe6c2cfb21a85aefc9a09634d4
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Wed Jul 29 16:37:48 2026 +0100
Add order withdrawal rate limiting (#67069)
* Add order withdrawal rate limiting
* Fix order withdrawal IP parsing static analysis
* Separate check and set rate limits
* Merge blocks
* Refactor set and clear rate limit methods
* Use WC_Geolocation to get ip address
* Prevent form submission if rate limit was unable to be set
* Fix lint
diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-rate-limit b/plugins/woocommerce/changelog/add-order-withdrawal-rate-limit
new file mode 100644
index 00000000000..444f4b3b191
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-rate-limit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Rate limit order withdrawal requests by IP address and email.
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
index 1e726cc17b5..da0d2064a6e 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -5,7 +5,9 @@ namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use Throwable;
+use WC_Geolocation;
use WC_Order;
+use WC_Rate_Limiter;
/**
* Processes order withdrawal form requests.
@@ -35,6 +37,9 @@ final class OrderWithdrawalFormProcessor {
private const LOGGER_SOURCE = 'order-withdrawal';
private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
private const ORDER_WITHDRAWAL_REQUESTED_VALUE = 'yes';
+ private const RATE_LIMIT_IP_PREFIX = 'order_withdrawal_ip_';
+ private const RATE_LIMIT_EMAIL_PREFIX = 'order_withdrawal_email_';
+ private const RATE_LIMIT_DELAY = MINUTE_IN_SECONDS / 2;
/**
* Process the current order withdrawal request.
@@ -247,6 +252,18 @@ final class OrderWithdrawalFormProcessor {
* @param array<string,string> $data Form data.
*/
private function submit_order_withdrawal( array $data ): bool {
+ $rate_limit_ids = $this->get_rate_limit_ids( $data );
+
+ if ( ! $this->check_rate_limits( $rate_limit_ids ) ) {
+ return false;
+ }
+
+ if ( ! $this->apply_rate_limits( $rate_limit_ids ) ) {
+ wc_add_notice( __( 'We could not submit your withdrawal request. Please try again or contact us if the problem continues.', 'woocommerce' ), 'error' );
+
+ return false;
+ }
+
$matched_order = $this->get_matching_order( $data );
if ( $matched_order ) {
@@ -256,6 +273,8 @@ final class OrderWithdrawalFormProcessor {
'error'
);
+ $this->apply_rate_limits( $rate_limit_ids, -1 );
+
return false;
}
@@ -264,6 +283,7 @@ final class OrderWithdrawalFormProcessor {
if ( ! $this->send_order_withdrawal_emails( $data, $matched_order ) ) {
wc_add_notice( __( 'We could not submit your withdrawal request. Please try again or contact us if the problem continues.', 'woocommerce' ), 'error' );
+ $this->apply_rate_limits( $rate_limit_ids, -1 );
return false;
}
@@ -275,6 +295,70 @@ final class OrderWithdrawalFormProcessor {
return true;
}
+ /**
+ * Check order withdrawal submission rate limits.
+ *
+ * @param string[] $rate_limit_ids Rate limit IDs.
+ */
+ private function check_rate_limits( array $rate_limit_ids ): bool {
+ foreach ( $rate_limit_ids as $rate_limit_id ) {
+ if ( WC_Rate_Limiter::retried_too_soon( $rate_limit_id ) ) {
+ wc_add_notice( __( 'Please wait before submitting another withdrawal request.', 'woocommerce' ), 'error' );
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Set or clear the order withdrawal submission rate limits.
+ *
+ * @param string[] $rate_limit_ids Rate limit IDs.
+ * @param int $delay Delay in seconds for the rate limit. Use -1 to clear the rate limit.
+ * @return bool True if all rate limits were applied, false otherwise.
+ */
+ private function apply_rate_limits( array $rate_limit_ids, int $delay = self::RATE_LIMIT_DELAY ): bool {
+ $applied_rate_limit_ids = array();
+
+ foreach ( $rate_limit_ids as $rate_limit_id ) {
+ if ( ! WC_Rate_Limiter::set_rate_limit( $rate_limit_id, $delay ) ) {
+ foreach ( $applied_rate_limit_ids as $applied_rate_limit_id ) {
+ WC_Rate_Limiter::set_rate_limit( $applied_rate_limit_id, -1 );
+ }
+
+ return false;
+ }
+
+ $applied_rate_limit_ids[] = $rate_limit_id;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get order withdrawal rate limit identifiers for the current request.
+ *
+ * @param array<string,string> $data Form data.
+ * @return string[]
+ */
+ private function get_rate_limit_ids( array $data ): array {
+ $rate_limit_ids = array();
+ $ip_address = WC_Geolocation::get_ip_address();
+ $email = strtolower( trim( $data[ self::FIELD_EMAIL ] ) );
+
+ if ( '' !== $ip_address ) {
+ $rate_limit_ids[] = self::RATE_LIMIT_IP_PREFIX . hash( 'sha256', $ip_address );
+ }
+
+ if ( '' !== $email ) {
+ $rate_limit_ids[] = self::RATE_LIMIT_EMAIL_PREFIX . hash( 'sha256', $email );
+ }
+
+ return $rate_limit_ids;
+ }
+
/**
* Get an order only when every submitted order-identifying field matches.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index c5db4cd17fa..fd1560da7dc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
use WC_Order;
+use WC_Rate_Limiter;
use WC_Unit_Test_Case;
/**
@@ -19,6 +20,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
private const FLUSH_QUEUE_OPTION = 'woocommerce_queue_flush_rewrite_rules';
private const MISSING_OPTION_MARK = '__woocommerce_order_withdrawal_missing_option__';
private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
+ private const RATE_LIMIT_PREFIX = 'order_withdrawal_';
/**
* The System Under Test.
@@ -48,6 +50,20 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
*/
private bool $had_request_method = false;
+ /**
+ * Original REMOTE_ADDR value.
+ *
+ * @var string|null
+ */
+ private ?string $original_remote_addr = null;
+
+ /**
+ * Whether REMOTE_ADDR existed before the test.
+ *
+ * @var bool
+ */
+ private bool $had_remote_addr = false;
+
/**
* Original WooCommerce session.
*
@@ -93,6 +109,8 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$this->had_request_method = filter_has_var( INPUT_SERVER, 'REQUEST_METHOD' );
$this->original_request_method = $this->had_request_method ? filter_input( INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_FULL_SPECIAL_CHARS ) : null;
+ $this->had_remote_addr = array_key_exists( 'REMOTE_ADDR', $_SERVER );
+ $this->original_remote_addr = $this->had_remote_addr ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : null;
$this->original_session = WC()->session;
$this->original_feature_option = get_option( self::FEATURE_OPTION, self::MISSING_OPTION_MARK );
$this->original_endpoint_option = get_option( self::ENDPOINT_OPTION, self::MISSING_OPTION_MARK );
@@ -104,6 +122,8 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$_POST = array();
$_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+ $this->clear_order_withdrawal_rate_limits();
$this->disable_feature();
delete_option( self::ENDPOINT_OPTION );
delete_option( self::FLUSH_QUEUE_OPTION );
@@ -122,10 +142,17 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
unset( $_SERVER['REQUEST_METHOD'] );
}
+ if ( $this->had_remote_addr ) {
+ $_SERVER['REMOTE_ADDR'] = (string) $this->original_remote_addr;
+ } else {
+ unset( $_SERVER['REMOTE_ADDR'] );
+ }
+
$this->restore_option( self::FEATURE_OPTION, $this->original_feature_option );
$this->restore_option( self::ENDPOINT_OPTION, $this->original_endpoint_option );
$this->restore_option( self::FLUSH_QUEUE_OPTION, $this->original_flush_queue_option );
wc_clear_notices();
+ $this->clear_order_withdrawal_rate_limits();
$this->delete_created_orders();
WC()->session = $this->original_session;
@@ -338,6 +365,19 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'already been submitted for this order', $error_notices[0]['notice'], 'The notice should explain that the order already has a withdrawal request.' );
$this->assertCount( 0, $capture['captures'], 'Duplicate matched submissions should not send notification emails.' );
$this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'Duplicate matched submissions should not add another order note.' );
+
+ wc_clear_notices();
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+ );
+
+ $second_state = $this->sut->process_current_request();
+ $second_error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'review', $second_state->screen, 'Duplicate matched submissions should not leave behind a rate limit.' );
+ $this->assertCount( 1, $second_error_notices, 'The second duplicate submission should add only the duplicate-order error notice.' );
+ $this->assertStringContainsString( 'already been submitted for this order', $second_error_notices[0]['notice'], 'The released rate limit should allow duplicate-order validation to run again.' );
} finally {
$capture['remove']();
}
@@ -427,6 +467,89 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->assertCount( 2, $capture['captures'], 'The processor should attempt both notification emails before surfacing the failure.' );
$this->assertNotEmpty( $error_notices, 'Email failures should add an error notice.' );
$this->assertStringContainsString( 'We could not submit your withdrawal request.', $error_notices[0]['notice'], 'The error notice should tell the user the submission did not complete.' );
+
+ wc_clear_notices();
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+ );
+
+ $second_state = $this->sut->process_current_request();
+ $second_error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'review', $second_state->screen, 'Email failures should not leave behind a rate limit.' );
+ $this->assertCount( 4, $capture['captures'], 'The second failed submission should attempt notification emails again.' );
+ $this->assertNotEmpty( $second_error_notices, 'The second email failure should add an error notice.' );
+ $this->assertStringContainsString( 'We could not submit your withdrawal request.', $second_error_notices[0]['notice'], 'The released rate limit should allow email delivery to be attempted again.' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should rate limit confirmed submissions by IP address before sending emails.
+ */
+ public function test_process_current_request_rate_limits_confirmed_submissions_by_ip_address(): void {
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999998' )
+ );
+
+ $first_state = $this->sut->process_current_request();
+
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array(
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => 'another@example.test',
+ OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION => 'another@example.test',
+ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999',
+ )
+ );
+
+ $second_state = $this->sut->process_current_request();
+ $error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'confirmation', $first_state->screen, 'The first confirmed submission should complete.' );
+ $this->assertSame( 'review', $second_state->screen, 'A repeated submission from the same IP should return to the review screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The rate-limited submission should not send additional notification emails.' );
+ $this->assertNotEmpty( $error_notices, 'Rate-limited submissions should add an error notice.' );
+ $this->assertStringContainsString( 'Please wait before submitting another withdrawal request.', $error_notices[0]['notice'], 'The notice should ask the customer to wait.' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should rate limit confirmed submissions by email before sending emails.
+ */
+ public function test_process_current_request_rate_limits_confirmed_submissions_by_email(): void {
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999998' )
+ );
+
+ $first_state = $this->sut->process_current_request();
+
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.11';
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+ );
+
+ $second_state = $this->sut->process_current_request();
+ $error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'confirmation', $first_state->screen, 'The first confirmed submission should complete.' );
+ $this->assertSame( 'review', $second_state->screen, 'A repeated submission for the same email should return to the review screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The rate-limited submission should not send additional notification emails.' );
+ $this->assertNotEmpty( $error_notices, 'Rate-limited submissions should add an error notice.' );
+ $this->assertStringContainsString( 'Please wait before submitting another withdrawal request.', $error_notices[0]['notice'], 'The notice should ask the customer to wait.' );
} finally {
$capture['remove']();
}
@@ -689,6 +812,22 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->created_order_ids = array();
}
+ /**
+ * Clear order withdrawal rate limits created during tests.
+ */
+ private function clear_order_withdrawal_rate_limits(): void {
+ global $wpdb;
+
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->prefix}wc_rate_limits WHERE rate_limit_key LIKE %s",
+ $wpdb->esc_like( self::RATE_LIMIT_PREFIX ) . '%'
+ )
+ );
+
+ \WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP );
+ }
+
/**
* Disable the order withdrawal feature.
*/