Commit 3b95a3cf86 for woocommerce
commit 3b95a3cf8697d08eb33a8a38c9c394aff75a6600
Author: Luiz Reis <luiz.reis@automattic.com>
Date: Thu Jan 22 12:15:38 2026 -0300
Fraud Protection: Display blocked session notice on purchase-related pages (#62839)
* Add blocked purchase notice on shop, cart, and checkout pages
Display a blocked session notice on purchase-related pages (shop, cart,
checkout, product taxonomies) for both blocks and shortcode implementations.
Uses wc_add_notice() on the 'wp' hook which fires early enough to work with
both StoreNoticesContainer (blocks) and wc_print_notices() (shortcodes).
This also ensures notices display even when the cart is empty, since
shortcode-specific hooks don't fire in that case.
Includes duplicate notice prevention and updates the Store API to use
the correct 'purchase' message context.
* Document cart emptying to prevent express payment methods
Express payment methods like PayPal are loaded via third-party SDKs that
don't respect WooCommerce's payment method filtering. Emptying the cart
when a session is blocked prevents these buttons from rendering, since
they have no cart to process.
* Preserve blocked session status across fraud checks
Once a session is blocked, it stays blocked until explicitly reset.
This prevents a race condition where emptying the cart (done during
block_session) causes subsequent fraud checks to return "allow" due to
the lower cart value, which would incorrectly unblock the session.
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
index 02f9e50545..b8ae656836 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/BlockedSessionNotice.php
@@ -52,30 +52,44 @@ class BlockedSessionNotice implements RegisterHooksInterface {
* @return void
*/
public function register(): void {
- add_action( 'woocommerce_before_checkout_form', array( $this, 'display_checkout_blocked_notice' ), 1, 0 );
- add_action( 'before_woocommerce_add_payment_method', array( $this, 'display_generic_blocked_notice' ), 1, 0 );
+ // Shop, cart, and checkout pages (both blocks and shortcode) - add notice via wc_add_notice on wp hook.
+ add_action( 'wp', array( $this, 'maybe_add_blocked_purchase_notice' ), 10, 0 );
+
+ add_action( 'before_woocommerce_add_payment_method', array( $this, 'maybe_display_generic_blocked_notice' ), 1, 0 );
}
/**
- * Display blocked notice on shortcode checkout page.
+ * Add blocked purchase notice on shop, cart, and checkout pages (both blocks and shortcode),
+ * if the session is blocked. Skips duplicate notices.
*
- * Shows a checkout-specific message explaining that the purchase cannot be
- * completed online and provides contact information for support.
+ * Uses wc_add_notice() to add an error notice that will be rendered by:
+ * - StoreNoticesContainer component for blocks
+ * - wc_print_notices() for shortcodes
*
* @internal
*
* @return void
*/
- public function display_checkout_blocked_notice(): void {
+ public function maybe_add_blocked_purchase_notice(): void {
if ( ! $this->session_manager->is_session_blocked() ) {
return;
}
- wc_print_notice( $this->get_message_html( 'checkout' ), 'error' );
+ if ( ! is_checkout() && ! is_cart() && ! is_shop() && ! is_product_taxonomy() ) {
+ return;
+ }
+
+ $message = $this->get_message_html( 'purchase' );
+
+ if ( wc_has_notice( $message, 'error' ) ) {
+ return;
+ }
+
+ wc_add_notice( $message, 'error' );
}
/**
- * Display blocked notice for non-checkout pages.
+ * Display blocked notice for non-cart/checkout pages, if the session is blocked.
*
* Shows a generic message explaining that the request cannot be
* processed online and provides contact information for support.
@@ -84,7 +98,7 @@ class BlockedSessionNotice implements RegisterHooksInterface {
*
* @return void
*/
- public function display_generic_blocked_notice(): void {
+ public function maybe_display_generic_blocked_notice(): void {
if ( ! $this->session_manager->is_session_blocked() ) {
return;
}
@@ -97,13 +111,13 @@ class BlockedSessionNotice implements RegisterHooksInterface {
*
* Includes a mailto link for the support email.
*
- * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
+ * @param string $context Message context: 'purchase' for purchase-specific message, 'generic' for general use.
* @return string HTML message with mailto link.
*/
public function get_message_html( string $context = 'generic' ): string {
$email = WC()->mailer()->get_from_address();
- if ( 'checkout' === $context ) {
+ if ( 'purchase' === $context ) {
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' ),
@@ -125,13 +139,13 @@ class BlockedSessionNotice implements RegisterHooksInterface {
*
* Used by Store API responses where HTML is not supported.
*
- * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
+ * @param string $context Message context: 'purchase' for purchase-specific message, 'generic' for general use.
* @return string Plaintext message with email address.
*/
public function get_message_plaintext( string $context = 'generic' ): string {
$email = WC()->mailer()->get_from_address();
- if ( 'checkout' === $context ) {
+ if ( 'purchase' === $context ) {
return sprintf(
/* translators: %s: support email address */
__( 'We are unable to process this request online. Please contact support (%s) to complete your purchase.', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
index 46d5ab3a11..856a528ec2 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
@@ -139,10 +139,25 @@ class DecisionHandler {
/**
* Update the session status based on the decision.
*
+ * Important: Once a session is blocked, it stays blocked until explicitly reset.
+ * This prevents race conditions where emptying the cart (done during block_session)
+ * causes subsequent fraud checks to return "allow" (due to lower cart value),
+ * which would incorrectly unblock the session.
+ *
* @param string $decision The validated decision to apply.
* @return void
*/
private function update_session_status( string $decision ): void {
+ // Don't overwrite a blocked session with an allow decision.
+ // Once blocked, a session should stay blocked until explicitly reset.
+ if ( ApiClient::DECISION_ALLOW === $decision && $this->session_manager->is_session_blocked() ) {
+ FraudProtectionController::log(
+ 'info',
+ 'Preserving blocked session status. Allow decision not applied to already-blocked session.'
+ );
+ return;
+ }
+
switch ( $decision ) {
case ApiClient::DECISION_ALLOW:
$this->session_manager->allow_session();
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
index 8ac90768cb..d583c40d9a 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
@@ -88,7 +88,11 @@ class SessionClearanceManager {
}
/**
- * Mark the current session as blocked.
+ * Mark the current session as blocked and empty the cart.
+ *
+ * Emptying the cart prevents express payment methods (e.g., PayPal) from
+ * rendering on cart pages, as they are loaded via third-party SDKs that
+ * don't respect WooCommerce's payment method filtering.
*
* @return void
*/
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 9d5d11dde4..52273b5d70 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -162,7 +162,7 @@ class Checkout extends AbstractCartRoute {
&& 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( 'checkout' ),
+ wc_get_container()->get( BlockedSessionNotice::class )->get_message_plaintext( 'purchase' ),
403
);
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
index 2cc710b7b4..376fddfa0b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/BlockedSessionNoticeTest.php
@@ -52,38 +52,85 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
*/
public function tearDown(): void {
parent::tearDown();
- remove_all_actions( 'woocommerce_before_checkout_form' );
remove_all_actions( 'before_woocommerce_add_payment_method' );
+ remove_all_actions( 'wp' );
delete_option( 'woocommerce_email_from_address' );
+ wc_clear_notices();
}
/**
- * @testdox Should display checkout-specific error notice when woocommerce_before_checkout_form action fires for blocked sessions.
+ * @testdox maybe_add_blocked_purchase_notice should add notice when session is blocked and on checkout page.
*/
- public function test_checkout_action_displays_blocked_message(): void {
+ public function test_blocked_purchase_notice_added_on_checkout(): 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();
+ // Mock being on checkout page.
+ add_filter( 'woocommerce_is_checkout', '__return_true' );
- $this->assertStringContainsString( 'unable to process this request online', $output, 'Should display blocked message on checkout' );
- $this->assertStringContainsString( 'to complete your purchase', $output, 'Should display checkout-specific message' );
- $this->assertStringContainsString( 'support@example.com', $output, 'Should include support email in message' );
- $this->assertStringContainsString( 'mailto:support@example.com', $output, 'Should include mailto link' );
+ do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+ $this->assertTrue( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should add purchase notice on checkout' );
+ }
+
+ /**
+ * @testdox maybe_add_blocked_purchase_notice should add notice when session is blocked and on cart page.
+ */
+ public function test_blocked_purchase_notice_added_on_cart(): void {
+ $this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );
+
+ // Mock being on cart page.
+ add_filter( 'woocommerce_is_cart', '__return_true' );
+
+ do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ remove_filter( 'woocommerce_is_cart', '__return_true' );
+
+ $this->assertTrue( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should add purchase notice on cart' );
}
/**
- * @testdox Should not display message when checkout action fires for non-blocked sessions.
+ * @testdox maybe_add_blocked_purchase_notice should not add notice when session is not blocked.
*/
- public function test_checkout_action_no_message_for_non_blocked_session(): void {
+ public function test_blocked_purchase_notice_not_added_when_session_allowed(): 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();
+ // Mock being on checkout page.
+ add_filter( 'woocommerce_is_checkout', '__return_true' );
- $this->assertEmpty( $output, 'Non-blocked sessions should not display any message' );
+ do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+ $this->assertFalse( wc_has_notice( $this->sut->get_message_html( 'purchase' ), 'error' ), 'Should not add notice when session is allowed' );
+ }
+
+ /**
+ * @testdox maybe_add_blocked_purchase_notice should not add duplicate notices.
+ */
+ public function test_blocked_purchase_notice_prevents_duplicates(): void {
+ $this->mock_session_manager->method( 'is_session_blocked' )->willReturn( true );
+
+ // Mock being on checkout page.
+ add_filter( 'woocommerce_is_checkout', '__return_true' );
+
+ do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ do_action( 'wp' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+
+ remove_filter( 'woocommerce_is_checkout', '__return_true' );
+
+ // Count error notices.
+ $notices = wc_get_notices( 'error' );
+ $message = $this->sut->get_message_html( 'purchase' );
+ $notice_count = 0;
+ foreach ( $notices as $notice ) {
+ if ( $notice['notice'] === $message ) {
+ ++$notice_count;
+ }
+ }
+
+ $this->assertEquals( 1, $notice_count, 'Should only have one notice even after calling twice' );
}
/**
@@ -116,10 +163,10 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox get_message_html should return checkout-specific message when context is 'checkout'.
+ * @testdox get_message_html should return purchase-specific message when context is 'purchase'.
*/
- public function test_get_message_html_checkout_context(): void {
- $message = $this->sut->get_message_html( 'checkout' );
+ public function test_get_message_html_purchase_context(): void {
+ $message = $this->sut->get_message_html( 'purchase' );
$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.',
@@ -141,10 +188,10 @@ class BlockedSessionNoticeTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox get_message_plaintext should return checkout-specific message when context is 'checkout'.
+ * @testdox get_message_plaintext should return purchase-specific message when context is 'purchase'.
*/
- public function test_get_message_plaintext_checkout_context(): void {
- $message = $this->sut->get_message_plaintext( 'checkout' );
+ public function test_get_message_plaintext_purchase_context(): void {
+ $message = $this->sut->get_message_plaintext( 'purchase' );
$this->assertEquals(
'We are unable to process this request online. Please contact support (support@example.com) to complete your purchase.',
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
index 22e37ed73a..50b95db534 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
@@ -54,9 +54,13 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should apply allow decision and update session to allowed.
+ * @testdox Should apply allow decision and update session to allowed when session is not blocked.
*/
public function test_apply_allow_decision(): void {
+ $this->session_manager
+ ->method( 'is_session_blocked' )
+ ->willReturn( false );
+
$this->session_manager
->expects( $this->once() )
->method( 'allow_session' );
@@ -66,6 +70,26 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
$this->assertSame( ApiClient::DECISION_ALLOW, $result );
}
+ /**
+ * @testdox Should preserve blocked session status when allow decision is received.
+ *
+ * This prevents race conditions where emptying the cart during block_session
+ * causes subsequent fraud checks to return "allow" (due to lower cart value).
+ */
+ public function test_allow_decision_does_not_overwrite_blocked_session(): void {
+ $this->session_manager
+ ->method( 'is_session_blocked' )
+ ->willReturn( true );
+
+ $this->session_manager
+ ->expects( $this->never() )
+ ->method( 'allow_session' );
+
+ $result = $this->sut->apply_decision( ApiClient::DECISION_ALLOW, array( 'session_id' => 'test' ) );
+
+ $this->assertLogged( 'info', 'Preserving blocked session status' );
+ }
+
/**
* @testdox Should apply block decision and update session to blocked.
*/
@@ -83,6 +107,10 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
* @testdox Should default to allow for invalid decision and log warning.
*/
public function test_invalid_decision_defaults_to_allow(): void {
+ $this->session_manager
+ ->method( 'is_session_blocked' )
+ ->willReturn( false );
+
$this->session_manager
->expects( $this->once() )
->method( 'allow_session' );
@@ -104,6 +132,10 @@ class DecisionHandlerTest extends WC_Unit_Test_Case {
}
);
+ $this->session_manager
+ ->method( 'is_session_blocked' )
+ ->willReturn( false );
+
$this->session_manager
->expects( $this->once() )
->method( 'allow_session' );