Commit fd60f79336 for woocommerce

commit fd60f79336024b0c998c9212d49b2d9d54a7ad23
Author: Luiz Reis <luiz.reis@automattic.com>
Date:   Sat Jan 10 14:06:23 2026 -0300

    [Fraud Protection] Block checkout for blocked sessions (#62691)

diff --git a/plugins/woocommerce/includes/class-wc-payment-gateways.php b/plugins/woocommerce/includes/class-wc-payment-gateways.php
index 7322df7d6a..3eda6b0d83 100644
--- a/plugins/woocommerce/includes/class-wc-payment-gateways.php
+++ b/plugins/woocommerce/includes/class-wc-payment-gateways.php
@@ -11,6 +11,8 @@
 use Automattic\WooCommerce\Enums\PaymentGatewayFeature;
 use Automattic\WooCommerce\Internal\Admin\Settings\Payments as SettingsPaymentsService;
 use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
 use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -392,6 +394,12 @@ All at %6$s
 	 * @return array The available payment gateways.
 	 */
 	public function get_available_payment_gateways() {
+		// Early return if fraud protection blocks session.
+		if ( wc_get_container()->get( FraudProtectionController::class )->feature_is_enabled()
+			&& wc_get_container()->get( SessionClearanceManager::class )->is_session_blocked() ) {
+			return array();
+		}
+
 		$_available_gateways = array();

 		foreach ( $this->payment_gateways as $gateway ) {
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
new file mode 100644
index 0000000000..317ea1c980
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * BlockedSessionNotice class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Handles blocked session messaging for fraud protection.
+ *
+ * This class provides:
+ * - Hook into shortcode checkout to display blocked notice
+ * - Message generation for both HTML (shortcode) and plaintext (Store API) contexts
+ *
+ * Note: Store API (block checkout) and payment gateway filtering are handled
+ * directly in WC Core classes (Checkout.php and WC_Payment_Gateways).
+ *
+ * @since 10.5.0
+ * @internal This class is part of the internal API and is subject to change without notice.
+ */
+class BlockedSessionNotice implements RegisterHooksInterface {
+
+	/**
+	 * Session clearance manager instance.
+	 *
+	 * @var SessionClearanceManager
+	 */
+	private SessionClearanceManager $session_manager;
+
+	/**
+	 * Initialize with dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param SessionClearanceManager $session_manager The session clearance manager instance.
+	 */
+	final public function init( SessionClearanceManager $session_manager ): void {
+		$this->session_manager = $session_manager;
+	}
+
+	/**
+	 * Register hooks for displaying blocked notice.
+	 *
+	 * This method should only be called when fraud protection is enabled.
+	 *
+	 * @return void
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_before_checkout_form', array( $this, 'display_blocked_notice' ), 1, 0 );
+	}
+
+	/**
+	 * Display blocked notice on shortcode checkout page.
+	 *
+	 * Shows a user-friendly message explaining that the request cannot be
+	 * processed online and provides contact information for support.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function display_blocked_notice(): void {
+		if ( ! $this->session_manager->is_session_blocked() ) {
+			return;
+		}
+
+		wc_print_notice( $this->get_message_html(), 'error' );
+	}
+
+	/**
+	 * Get the blocked session message as HTML.
+	 *
+	 * Includes a mailto link for the support email. Used by shortcode checkout.
+	 *
+	 * @return string HTML message with mailto link.
+	 */
+	public function get_message_html(): string {
+		$email = WC()->mailer()->get_from_address();
+
+		return sprintf(
+			/* translators: %1$s: mailto link, %2$s: email address */
+			__( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> to complete your purchase.', 'woocommerce' ),
+			esc_url( 'mailto:' . $email ),
+			esc_html( $email )
+		);
+	}
+
+	/**
+	 * Get the blocked session message as plaintext.
+	 *
+	 * Used by Store API responses where HTML is not supported.
+	 *
+	 * @return string Plaintext message with email address.
+	 */
+	public function get_message_plaintext(): string {
+		$email = WC()->mailer()->get_from_address();
+
+		return sprintf(
+			/* translators: %s: support email address */
+			__( 'We are unable to process this request online. Please contact support (%s) to complete your purchase.', 'woocommerce' ),
+			$email
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/FraudProtectionController.php b/plugins/woocommerce/src/Internal/FraudProtection/FraudProtectionController.php
index 3bb1ef72c0..17634d4dad 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/FraudProtectionController.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/FraudProtectionController.php
@@ -37,6 +37,13 @@ class FraudProtectionController implements RegisterHooksInterface {
 	 */
 	private JetpackConnectionManager $connection_manager;

+	/**
+	 * Blocked session notice instance.
+	 *
+	 * @var BlockedSessionNotice
+	 */
+	private BlockedSessionNotice $blocked_session_notice;
+
 	/**
 	 * Register hooks.
 	 */
@@ -49,12 +56,19 @@ class FraudProtectionController implements RegisterHooksInterface {
 	 * Initialize the instance, runs when the instance is created by the dependency injection container.
 	 *
 	 * @internal
-	 * @param FeaturesController       $features_controller The instance of FeaturesController to use.
-	 * @param JetpackConnectionManager $connection_manager  The instance of JetpackConnectionManager to use.
+	 *
+	 * @param FeaturesController       $features_controller      The instance of FeaturesController to use.
+	 * @param JetpackConnectionManager $connection_manager       The instance of JetpackConnectionManager to use.
+	 * @param BlockedSessionNotice     $blocked_session_notice   The instance of BlockedSessionNotice to use.
 	 */
-	final public function init( FeaturesController $features_controller, JetpackConnectionManager $connection_manager ): void {
-		$this->features_controller = $features_controller;
-		$this->connection_manager  = $connection_manager;
+	final public function init(
+		FeaturesController $features_controller,
+		JetpackConnectionManager $connection_manager,
+		BlockedSessionNotice $blocked_session_notice
+	): void {
+		$this->features_controller    = $features_controller;
+		$this->connection_manager     = $connection_manager;
+		$this->blocked_session_notice = $blocked_session_notice;
 	}

 	/**
@@ -68,8 +82,7 @@ class FraudProtectionController implements RegisterHooksInterface {
 			return;
 		}

-		// Future implementation: Register hooks and initialize components here.
-		// For now, this is a placeholder for the infrastructure.
+		$this->blocked_session_notice->register();
 	}

 	/**
@@ -119,11 +132,15 @@ class FraudProtectionController implements RegisterHooksInterface {
 	 * Check if fraud protection feature is enabled.
 	 *
 	 * This method can be used by other fraud protection classes to check
-	 * the feature flag status.
+	 * the feature flag status. Returns false (fail-open) if init hasn't run yet.
 	 *
-	 * @return bool True if enabled.
+	 * @return bool True if enabled, false if not enabled or init hasn't run yet.
 	 */
 	public function feature_is_enabled(): bool {
+		// Fail-open: don't block if init hasn't run yet to avoid FeaturesController translation notices.
+		if ( ! did_action( 'init' ) ) {
+			return false;
+		}
 		return $this->features_controller->feature_is_enabled( 'fraud_protection' );
 	}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 00ac3ad489..d488769982 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -8,6 +8,9 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
 use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
 use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
+use Automattic\WooCommerce\Internal\FraudProtection\FraudProtectionController;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;

 /**
  * Checkout class.
@@ -153,6 +156,16 @@ class Checkout extends AbstractCartRoute {
 			$response = $nonce_check;
 		}

+		// Block early 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() ) {
+			$response = $this->get_route_error_response(
+				'woocommerce_rest_checkout_error',
+				wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext(),
+				403
+			);
+		}
+
 		if ( ! $response ) {
 			try {
 				$response = $this->get_response_by_request_method( $request );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-payment-gateways-test.php b/plugins/woocommerce/tests/php/includes/class-wc-payment-gateways-test.php
index 610b5a771a..73e3ce43bc 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-payment-gateways-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-payment-gateways-test.php
@@ -3,6 +3,8 @@
  * @package WooCommerce\Tests\PaymentGateways
  */

+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+
 /**
  * Class WC_Payment_Gateways_Test.
  */
@@ -25,6 +27,15 @@ class WC_Payment_Gateways_Test extends WC_Unit_Test_Case {
 		$this->sut->init();
 	}

+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		delete_option( 'woocommerce_feature_fraud_protection_enabled' );
+		wc_get_container()->get( SessionClearanceManager::class )->reset_session();
+	}
+
 	/**
 	 * Test that enabling a gateway sends an email to the site admin and logs the event.
 	 */
@@ -85,6 +96,36 @@ class WC_Payment_Gateways_Test extends WC_Unit_Test_Case {
 		remove_filter( 'wp_mail', $watcher );
 	}

+	/**
+	 * Test that payment gateways are hidden when fraud protection blocks the session.
+	 */
+	public function test_get_available_payment_gateways_returns_empty_when_session_blocked() {
+		// Enable fraud protection and block the session.
+		update_option( 'woocommerce_feature_fraud_protection_enabled', 'yes' );
+		wc_get_container()->get( SessionClearanceManager::class )->block_session();
+
+		$this->enable_all_gateways();
+
+		$gateways = $this->sut->get_available_payment_gateways();
+
+		$this->assertEmpty( $gateways, 'Should return empty array when session is blocked' );
+	}
+
+	/**
+	 * Test that payment gateways are returned when fraud protection is disabled, even if session is blocked.
+	 */
+	public function test_get_available_payment_gateways_returns_gateways_when_feature_disabled() {
+		// Disable fraud protection but block the session.
+		update_option( 'woocommerce_feature_fraud_protection_enabled', 'no' );
+		wc_get_container()->get( SessionClearanceManager::class )->block_session();
+
+		$this->enable_all_gateways();
+
+		$gateways = $this->sut->get_available_payment_gateways();
+
+		$this->assertNotEmpty( $gateways, 'Should return gateways when feature is disabled' );
+	}
+
 	/**
 	 * Test get_payment_gateway_name_by_id returns gateway title for known gateway.
 	 *
@@ -109,4 +150,13 @@ class WC_Payment_Gateways_Test extends WC_Unit_Test_Case {
 		$result = $this->sut->get_payment_gateway_name_by_id( 'nonexistent_gateway' );
 		$this->assertEquals( 'nonexistent_gateway', $result );
 	}
+
+	/**
+	 * Enable all payment gateways.
+	 */
+	private function enable_all_gateways() {
+		foreach ( $this->sut->payment_gateways() as $gateway ) {
+			$gateway->enabled = 'yes';
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 3aee672561..10e5628bf6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
 use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
 use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
 use Automattic\WooCommerce\StoreApi\Routes\V1\Checkout as CheckoutRoute;
 use Automattic\WooCommerce\StoreApi\SchemaController;
 use Automattic\WooCommerce\Blocks\Package;
@@ -126,6 +127,9 @@ class Checkout extends MockeryTestCase {
 		remove_all_actions( 'woocommerce_store_api_checkout_order_processed' );
 		remove_all_actions( 'woocommerce_valid_order_statuses_for_payment' );

+		delete_option( 'woocommerce_feature_fraud_protection_enabled' );
+		wc_get_container()->get( SessionClearanceManager::class )->reset_session();
+
 		update_option( 'woocommerce_ship_to_countries', 'all' );
 		update_option( 'woocommerce_allowed_countries', 'all' );
 		update_option( 'woocommerce_enable_guest_checkout', 'yes' );
@@ -1788,4 +1792,52 @@ class Checkout extends MockeryTestCase {
 		// Order shouldn't stay in custom status, instead we let payment gateway set the correct status.
 		$this->assertEquals( 'on-hold', $order->get_status(), 'Order status should be controlled by the payment gateway, not remain custom.' );
 	}
+
+	/**
+	 * Test that checkout is blocked when fraud protection blocks the session.
+	 */
+	public function test_checkout_blocked_when_session_blocked() {
+		// Enable fraud protection and block the session.
+		update_option( 'woocommerce_feature_fraud_protection_enabled', 'yes' );
+		wc_get_container()->get( SessionClearanceManager::class )->block_session();
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '',
+					'email'      => 'testaccount@test.com',
+				),
+				'shipping_address' => (object) array(
+					'first_name' => 'test',
+					'last_name'  => 'test',
+					'company'    => '',
+					'address_1'  => 'test',
+					'address_2'  => '',
+					'city'       => 'test',
+					'state'      => '',
+					'postcode'   => 'cb241ab',
+					'country'    => 'GB',
+					'phone'      => '',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 403, $response->get_status(), 'Should return 403 when session is blocked' );
+		$this->assertEquals( 'woocommerce_rest_checkout_error', $response->get_data()['code'] );
+		$this->assertStringContainsString( 'unable to process this request online', $response->get_data()['message'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
new file mode 100644
index 0000000000..33452e182b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * BlockedSessionNoticeTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+
+/**
+ * Tests for BlockedSessionNotice.
+ *
+ * @covers \Automattic\WooCommerce\Internal\FraudProtection\BlockedSessionNotice
+ */
+class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var BlockedSessionNotice
+	 */
+	private $sut;
+
+	/**
+	 * Mock session clearance manager.
+	 *
+	 * @var SessionClearanceManager|\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $mock_session_manager;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->mock_session_manager = $this->createMock( SessionClearanceManager::class );
+
+		$this->sut = new BlockedSessionNotice();
+		$this->sut->init( $this->mock_session_manager );
+		$this->sut->register();
+
+		// Set a custom support email.
+		update_option( 'woocommerce_email_from_address', 'support@example.com' );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		remove_all_actions( 'woocommerce_before_checkout_form' );
+		delete_option( 'woocommerce_email_from_address' );
+	}
+
+	/**
+	 * @testdox Should display error notice when woocommerce_before_checkout_form action fires for blocked sessions.
+	 */
+	public function test_checkout_action_displays_blocked_message(): void {
+		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );
+
+		ob_start();
+		do_action( 'woocommerce_before_checkout_form' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( 'unable to process this request online', $output, 'Should display blocked message on checkout' );
+		$this->assertStringContainsString( 'support@example.com', $output, 'Should include support email in message' );
+		$this->assertStringContainsString( 'mailto:support@example.com', $output, 'Should include mailto link' );
+	}
+
+	/**
+	 * @testdox Should not display message when checkout action fires for non-blocked sessions.
+	 */
+	public function test_checkout_action_no_message_for_non_blocked_session(): void {
+		$this->mock_session_manager->method( 'is_session_blocked' )->willReturn( false );
+
+		ob_start();
+		do_action( 'woocommerce_before_checkout_form' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		$output = ob_get_clean();
+
+		$this->assertEmpty( $output, 'Non-blocked sessions should not display any message' );
+	}
+
+	/**
+	 * @testdox get_message_html should return the expected HTML message with mailto link.
+	 */
+	public function test_get_message_html(): void {
+		$message = $this->sut->get_message_html();
+
+		$this->assertEquals(
+			'We are unable to process this request online. Please <a href="mailto:support@example.com">contact support (support@example.com)</a> to complete your purchase.',
+			$message
+		);
+	}
+
+	/**
+	 * @testdox get_message_plaintext should return the expected plaintext message.
+	 */
+	public function test_get_message_plaintext(): void {
+		$message = $this->sut->get_message_plaintext();
+
+		$this->assertEquals(
+			'We are unable to process this request online. Please contact support (support@example.com) to complete your purchase.',
+			$message
+		);
+	}
+}