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