Commit 3cf34e16cf for woocommerce

commit 3cf34e16cf837d1b6181bdf39e074238b36df27a
Author: Luiz Reis <luiz.reis@automattic.com>
Date:   Thu Jan 22 13:06:31 2026 -0300

    Fraud Protection: Block cart modifications for fraud-blocked sessions (#62867)

    Implements two-level blocking for defense in depth:
    - Store API: AbstractCartRoute returns 403 for mutation requests (early rejection)
    - WC_Cart: add_to_cart/remove_cart_item/set_quantity block directly

diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index 51a0971976..c266b5e9bd 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -12,8 +12,10 @@
 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
 use Automattic\WooCommerce\Internal\FraudProtection\CartEventTracker;
 use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
 use Automattic\WooCommerce\Utilities\DiscountsUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Utilities\ShippingUtil;
@@ -1063,6 +1065,14 @@ class WC_Cart extends WC_Legacy_Cart {
 	 */
 	public function add_to_cart( $product_id = 0, $quantity = 1, $variation_id = 0, $variation = array(), $cart_item_data = array() ) {
 		try {
+			// Block add-to-cart if session is blocked by fraud protection.
+			if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled()
+				&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
+				throw new Exception(
+					wc_get_container()->get( BlockedSessionNotice::class )->get_message_html( 'purchase' )
+				);
+			}
+
 			$product_id   = absint( $product_id );
 			$variation_id = absint( $variation_id );

@@ -1370,6 +1380,16 @@ class WC_Cart extends WC_Legacy_Cart {
 	 * @return bool
 	 */
 	public function remove_cart_item( $cart_item_key ) {
+		// Block remove if session is blocked by fraud protection.
+		if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled()
+			&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
+			wc_add_notice(
+				wc_get_container()->get( BlockedSessionNotice::class )->get_message_html( 'purchase' ),
+				'error'
+			);
+			return false;
+		}
+
 		if ( isset( $this->cart_contents[ $cart_item_key ] ) ) {
 			$this->removed_cart_contents[ $cart_item_key ] = $this->cart_contents[ $cart_item_key ];

@@ -1436,6 +1456,16 @@ class WC_Cart extends WC_Legacy_Cart {
 			return $this->remove_cart_item( $cart_item_key );
 		}

+		// Block quantity update if session is blocked by fraud protection.
+		if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled()
+			&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
+			wc_add_notice(
+				wc_get_container()->get( BlockedSessionNotice::class )->get_message_html( 'purchase' ),
+				'error'
+			);
+			return false;
+		}
+
 		// Update qty.
 		$old_quantity                                      = $this->cart_contents[ $cart_item_key ]['quantity'];
 		$this->cart_contents[ $cart_item_key ]['quantity'] = $quantity;
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index 84d680e352..99f1e3e91a 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -4,6 +4,9 @@ namespace Automattic\WooCommerce\StoreApi\Routes\V1;

 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\SchemaController;
 use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
@@ -120,6 +123,18 @@ abstract class AbstractCartRoute extends AbstractRoute {
 			$response = $nonce_check;
 		}

+		// Block cart modifications if session is blocked by fraud protection.
+		if ( ! $response && $this->is_update_request( $request ) ) {
+			if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled()
+				&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
+				$response = $this->get_route_error_response(
+					'woocommerce_rest_cart_error',
+					wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'purchase' ),
+					403
+				);
+			}
+		}
+
 		if ( ! $response ) {
 			try {
 				$response = $this->get_response_by_request_method( $request );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartFraudProtection.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartFraudProtection.php
new file mode 100644
index 0000000000..872fc34d69
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartFraudProtection.php
@@ -0,0 +1,163 @@
+<?php
+/**
+ * Cart Fraud Protection Tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+
+/**
+ * Cart Fraud Protection Tests.
+ *
+ * Tests that cart modification routes are blocked when a session is blocked by fraud protection.
+ * All cart routes extend AbstractCartRoute which checks session status before processing mutations.
+ */
+class CartFraudProtection extends ControllerTestCase {
+
+	/**
+	 * Test product.
+	 *
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Mock FraudProtectionController.
+	 *
+	 * @var FraudProtectionController|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $fraud_controller_mock;
+
+	/**
+	 * Mock SessionClearanceManager.
+	 *
+	 * @var SessionClearanceManager|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $session_manager_mock;
+
+	/**
+	 * Mock BlockedSessionNotice.
+	 *
+	 * @var BlockedSessionNotice|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $blocked_notice_mock;
+
+	/**
+	 * Setup test product data.
+	 */
+	protected function setUp(): void {
+		parent::setUp();
+
+		$fixtures      = new FixtureData();
+		$this->product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Test Product',
+				'stock_status'  => ProductStockStatus::IN_STOCK,
+				'regular_price' => 10,
+			)
+		);
+
+		wc_empty_cart();
+
+		$this->fraud_controller_mock = $this->createMock( FraudProtectionController::class );
+		$this->session_manager_mock  = $this->createMock( SessionClearanceManager::class );
+		$this->blocked_notice_mock   = $this->createMock( BlockedSessionNotice::class );
+
+		wc_get_container()->replace( FraudProtectionController::class, $this->fraud_controller_mock );
+		wc_get_container()->replace( SessionClearanceManager::class, $this->session_manager_mock );
+		wc_get_container()->replace( BlockedSessionNotice::class, $this->blocked_notice_mock );
+	}
+
+	/**
+	 * Tear down.
+	 */
+	protected function tearDown(): void {
+		wc_get_container()->reset_replacement( FraudProtectionController::class );
+		wc_get_container()->reset_replacement( SessionClearanceManager::class );
+		wc_get_container()->reset_replacement( BlockedSessionNotice::class );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Cart mutations return 403 with blocked message when session is blocked.
+	 */
+	public function test_cart_mutations_blocked_when_session_blocked(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( true );
+		$this->blocked_notice_mock->method( 'get_message_plaintext' )->willReturn( 'Session blocked message' );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'id'       => $this->product->get_id(),
+				'quantity' => 1,
+			)
+		);
+
+		$this->assertApiResponse(
+			$request,
+			403,
+			array(
+				'code'    => 'woocommerce_rest_cart_error',
+				'message' => 'Session blocked message',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Cart mutations succeed when session is allowed.
+	 */
+	public function test_cart_mutations_allowed_when_session_allowed(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( false );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'id'       => $this->product->get_id(),
+				'quantity' => 1,
+			)
+		);
+
+		$this->assertApiResponse( $request, 201 );
+	}
+
+	/**
+	 * @testdox Cart mutations succeed when fraud protection is disabled.
+	 */
+	public function test_cart_mutations_allowed_when_feature_disabled(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( false );
+		$this->session_manager_mock->expects( $this->never() )->method( 'is_session_blocked' );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'id'       => $this->product->get_id(),
+				'quantity' => 1,
+			)
+		);
+
+		$this->assertApiResponse( $request, 201 );
+	}
+
+	/**
+	 * @testdox GET requests are not blocked (read-only).
+	 */
+	public function test_get_requests_allowed_when_session_blocked(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( true );
+
+		$this->assertApiResponse( '/wc/store/v1/cart', 200 );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartBlockingTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartBlockingTest.php
new file mode 100644
index 0000000000..44717c507d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/CartBlockingTest.php
@@ -0,0 +1,214 @@
+<?php
+/**
+ * CartBlockingTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+
+/**
+ * Tests for cart blocking when session is blocked by fraud protection.
+ *
+ * Tests WC_Cart method integration (add_to_cart, remove_cart_item, set_quantity).
+ *
+ * @covers \WC_Cart
+ */
+class CartBlockingTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * Mock FraudProtectionController.
+	 *
+	 * @var FraudProtectionController|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $fraud_controller_mock;
+
+	/**
+	 * Mock SessionClearanceManager.
+	 *
+	 * @var SessionClearanceManager|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $session_manager_mock;
+
+	/**
+	 * Mock BlockedSessionNotice.
+	 *
+	 * @var BlockedSessionNotice|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $blocked_notice_mock;
+
+	/**
+	 * Test product.
+	 *
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->product = \WC_Helper_Product::create_simple_product();
+
+		$this->fraud_controller_mock = $this->createMock( FraudProtectionController::class );
+		$this->session_manager_mock  = $this->createMock( SessionClearanceManager::class );
+		$this->blocked_notice_mock   = $this->createMock( BlockedSessionNotice::class );
+
+		wc_get_container()->replace( FraudProtectionController::class, $this->fraud_controller_mock );
+		wc_get_container()->replace( SessionClearanceManager::class, $this->session_manager_mock );
+		wc_get_container()->replace( BlockedSessionNotice::class, $this->blocked_notice_mock );
+
+		wc_empty_cart();
+		wc_clear_notices();
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		wc_get_container()->reset_replacement( FraudProtectionController::class );
+		wc_get_container()->reset_replacement( SessionClearanceManager::class );
+		wc_get_container()->reset_replacement( BlockedSessionNotice::class );
+
+		$this->product->delete( true );
+		wc_empty_cart();
+		wc_clear_notices();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox add_to_cart returns false and adds notice when session is blocked.
+	 */
+	public function test_add_to_cart_blocked_when_session_blocked(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( true );
+		$this->blocked_notice_mock->method( 'get_message_html' )->willReturn( 'Blocked message' );
+
+		$result = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+
+		$this->assertFalse( $result );
+		$this->assertEquals( 0, WC()->cart->get_cart_contents_count() );
+		$this->assertTrue( wc_has_notice( 'Blocked message', 'error' ) );
+	}
+
+	/**
+	 * @testdox add_to_cart succeeds when session is allowed.
+	 */
+	public function test_add_to_cart_allowed_when_session_allowed(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( false );
+
+		$result = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+
+		$this->assertNotFalse( $result );
+		$this->assertEquals( 1, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox add_to_cart succeeds when fraud protection is disabled.
+	 */
+	public function test_add_to_cart_allowed_when_feature_disabled(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( false );
+		$this->session_manager_mock->expects( $this->never() )->method( 'is_session_blocked' );
+
+		$result = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+
+		$this->assertNotFalse( $result );
+		$this->assertEquals( 1, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox remove_cart_item returns false when session is blocked.
+	 */
+	public function test_remove_cart_item_blocked_when_session_blocked(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock
+			->method( 'is_session_blocked' )
+			->willReturnOnConsecutiveCalls( false, true ); // Allow add, block remove.
+		$this->blocked_notice_mock->method( 'get_message_html' )->willReturn( 'Blocked message' );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->remove_cart_item( $cart_item_key );
+
+		$this->assertFalse( $result );
+		$this->assertEquals( 1, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox remove_cart_item succeeds when session is allowed.
+	 */
+	public function test_remove_cart_item_allowed_when_session_allowed(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( false );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->remove_cart_item( $cart_item_key );
+
+		$this->assertTrue( $result );
+		$this->assertEquals( 0, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox remove_cart_item succeeds when fraud protection is disabled.
+	 */
+	public function test_remove_cart_item_allowed_when_feature_disabled(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( false );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->remove_cart_item( $cart_item_key );
+
+		$this->assertTrue( $result );
+		$this->assertEquals( 0, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox set_quantity returns false when session is blocked.
+	 */
+	public function test_set_quantity_blocked_when_session_blocked(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock
+			->method( 'is_session_blocked' )
+			->willReturnOnConsecutiveCalls( false, true ); // Allow add, block update.
+		$this->blocked_notice_mock->method( 'get_message_html' )->willReturn( 'Blocked message' );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->set_quantity( $cart_item_key, 5 );
+
+		$this->assertFalse( $result );
+		$this->assertEquals( 1, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox set_quantity succeeds when session is allowed.
+	 */
+	public function test_set_quantity_allowed_when_session_allowed(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( true );
+		$this->session_manager_mock->method( 'is_session_blocked' )->willReturn( false );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->set_quantity( $cart_item_key, 5 );
+
+		$this->assertTrue( $result );
+		$this->assertEquals( 5, WC()->cart->get_cart_contents_count() );
+	}
+
+	/**
+	 * @testdox set_quantity succeeds when fraud protection is disabled.
+	 */
+	public function test_set_quantity_allowed_when_feature_disabled(): void {
+		$this->fraud_controller_mock->method( 'feature_is_enabled' )->willReturn( false );
+
+		$cart_item_key = WC()->cart->add_to_cart( $this->product->get_id(), 1 );
+		$result        = WC()->cart->set_quantity( $cart_item_key, 5 );
+
+		$this->assertTrue( $result );
+		$this->assertEquals( 5, WC()->cart->get_cart_contents_count() );
+	}
+}