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