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