From 6d2c15d3dbd7be02ff1072d505f43888dc4ed294 Mon Sep 17 00:00:00 2001 From: Luiz Reis Date: Wed, 4 Mar 2026 16:45:48 -0300 Subject: [PATCH 1/3] Fraud Protection: Add Blackbox integration for PayPal express checkout PayPal express checkout (product page, cart, mini-cart) bypasses the standard WC checkout pipeline. Hook into PayPal's CreateOrder AJAX endpoint to verify sessions before PayPal order creation. JS fetch interceptor injects the Blackbox session ID into ppc-create-order requests (same pattern as PayPal's reCAPTCHA module) and resets Blackbox after the fetch returns so subsequent payment attempts get a fresh session for evaluation. --- assets/js/paypal-express.js | 48 +++++ src/Compat/PayPalCompat.php | 220 ++++++++++++++++++++ src/FraudProtectionController.php | 13 +- tests/js/paypal-express.test.js | 138 +++++++++++++ tests/php/src/Compat/PayPalCompatTest.php | 232 ++++++++++++++++++++++ woocommerce-fraud-protection.php | 7 +- 6 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 assets/js/paypal-express.js create mode 100644 src/Compat/PayPalCompat.php create mode 100644 tests/js/paypal-express.test.js create mode 100644 tests/php/src/Compat/PayPalCompatTest.php diff --git a/assets/js/paypal-express.js b/assets/js/paypal-express.js new file mode 100644 index 0000000..6a31efe --- /dev/null +++ b/assets/js/paypal-express.js @@ -0,0 +1,48 @@ +/** + * PayPal Express fetch interceptor for Woo Fraud Protection. + * + * Intercepts PayPal's ppc-create-order AJAX calls to inject the Blackbox + * session ID into the request body. This allows the server-side PayPalCompat + * handler to verify the session before PayPal order creation. + * + * Follows the same fetch interceptor pattern as PayPal's reCAPTCHA module. + * + * Resets Blackbox after the CreateOrder fetch returns so subsequent payment + * attempts (retry, different method) get a fresh session for evaluation. + */ +( function () { + if ( ! window.wcFraudProtection ) { + return; + } + + const fp = window.wcFraudProtection; + const originalFetch = window.fetch; + + window.fetch = async function ( resource, init ) { + init = init || {}; + const url = typeof resource === 'string' ? resource : resource.url; + + if ( ! url || url.indexOf( 'ppc-create-order' ) === -1 ) { + return originalFetch.call( this, resource, init ); + } + + // Acquire session ID (5s timeout, fail-open on timeout). + const sessionId = await fp.acquireSessionId(); + + try { + const body = JSON.parse( init.body ); + body[ fp.config.sessionIdField ] = sessionId; + const jsonBody = JSON.stringify( body ); + init.body = jsonBody; + } catch ( e ) { + // Fail-open: send the request without session ID. + } + + try { + return await originalFetch.call( this, resource, init ); + } finally { + // Reset Blackbox so subsequent payment attempts get a fresh session. + fp.reset(); + } + }; +} )(); diff --git a/src/Compat/PayPalCompat.php b/src/Compat/PayPalCompat.php new file mode 100644 index 0000000..cd0b62d --- /dev/null +++ b/src/Compat/PayPalCompat.php @@ -0,0 +1,220 @@ +session_verifier = $session_verifier; + $this->blocked_session_notice = $blocked_session_notice; + } + + /** + * Register hooks for PayPal express fraud protection. + * + * @internal + * + * @return void + */ + public function register(): void { + add_action( 'woocommerce_paypal_payments_create_order_request_started', array( $this, 'verify_and_block_create_order' ) ); + add_filter( 'woocommerce_fraud_protection_enqueue_blackbox_scripts', array( $this, 'should_enqueue_blackbox' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_paypal_script' ), 20 ); + } + + /** + * Verify the session and block the PayPal CreateOrder request if needed. + * + * Called during `woocommerce_paypal_payments_create_order_request_started`, + * which fires after nonce validation but before PayPal order creation. + * + * On BLOCK, sends a JSON error response and terminates execution via + * wp_send_json_error(). On ALLOW, returns normally and the PayPal flow + * continues. + * + * @internal + * + * @param array $data The CreateOrder request data from PayPal Payments. + * @return void + */ + public function verify_and_block_create_order( array $data ): void { + $session_id = sanitize_text_field( $data[ BlackboxScriptHandler::SESSION_ID_FIELD ] ?? '' ); + + $decision = $this->session_verifier->verify_session( $session_id, self::ORDER_CREATION_SOURCE, 0, $data ); + + if ( ApiClient::DECISION_BLOCK === $decision ) { + wp_send_json_error( + array( 'message' => $this->blocked_session_notice->get_message_plaintext( 'purchase' ) ), + 403 + ); + } + } + + /** + * Filter whether Blackbox scripts should be enqueued on the current page. + * + * Extends the default enqueue logic (checkout, pay-for-order, add-payment-method) + * to also load on pages where PayPal express buttons can trigger checkout: + * product pages, cart pages, and any page when mini-cart buttons are enabled + * (the mini-cart renders in the header/template, so buttons appear on every page). + * + * @internal + * + * @param bool $should Whether scripts are already set to be enqueued. + * @return bool + */ + public function should_enqueue_blackbox( bool $should ): bool { + if ( $should ) { + return true; + } + + if ( ! $this->is_paypal_available() ) { + return false; + } + + return is_product() + || is_cart() + || has_block( 'woocommerce/cart' ) + || $this->is_paypal_mini_cart_enabled(); + } + + /** + * Enqueue the PayPal express fetch interceptor script. + * + * Only enqueues when Blackbox init script is already loaded (which means + * Blackbox is configured and ready on the current page). + * + * @internal + * + * @return void + */ + public function enqueue_paypal_script(): void { + if ( ! wp_script_is( 'wc-fraud-protection-blackbox-init', 'enqueued' ) ) { + return; + } + + wp_enqueue_script( + 'wc-fraud-protection-paypal-express', + WC_FRAUD_PROTECTION_PLUGIN_URL . 'assets/js/paypal-express.js', + array( 'wc-fraud-protection-blackbox-init' ), + WC_FRAUD_PROTECTION_VERSION, + array( 'in_footer' => true ) + ); + } + + /** + * Check if a gateway ID belongs to PayPal Payments. + * + * @param string $gateway_id The gateway ID to check. + * @return bool + */ + private function is_paypal_gateway( string $gateway_id ): bool { + return 0 === strpos( $gateway_id, self::PAYPAL_GATEWAY_PREFIX ); + } + + /** + * Check if any PayPal Payments gateway is available. + * + * @return bool + */ + private function is_paypal_available(): bool { + if ( ! function_exists( 'WC' ) || ! WC()->payment_gateways() ) { + return false; + } + + $gateways = WC()->payment_gateways()->get_available_payment_gateways(); + + foreach ( array_keys( $gateways ) as $id ) { + if ( $this->is_paypal_gateway( $id ) ) { + return true; + } + } + + return false; + } + + /** + * Check if PayPal Payments has mini-cart smart buttons enabled. + * + * When enabled, PayPal renders express buttons inside the mini-cart widget + * which appears on every frontend page (header/template part). Reads the + * same `smart_button_locations` setting that PayPal uses to decide where + * to load its scripts. + * + * @return bool + */ + private function is_paypal_mini_cart_enabled(): bool { + $ppcp_settings = get_option( 'woocommerce-ppcp-settings', array() ); + + if ( ! is_array( $ppcp_settings ) ) { + return false; + } + + $locations = $ppcp_settings['smart_button_locations'] ?? array(); + + return is_array( $locations ) && in_array( 'mini-cart', $locations, true ); + } +} diff --git a/src/FraudProtectionController.php b/src/FraudProtectionController.php index 99d4bf3..5b3a589 100644 --- a/src/FraudProtectionController.php +++ b/src/FraudProtectionController.php @@ -90,6 +90,13 @@ class FraudProtectionController /* implements RegisterHooksInterface */ { */ private SessionBlockingHandler $session_blocking_handler; + /** + * PayPal compatibility instance. + * + * @var Compat\PayPalCompat + */ + private Compat\PayPalCompat $paypal_compat; + /** * Register hooks. */ @@ -112,6 +119,7 @@ public function register(): void { * @param ShortcodeCheckoutProtector $shortcode_checkout_protector The instance of ShortcodeCheckoutProtector to use. * @param AddPaymentMethodProtector $add_payment_method_protector The instance of AddPaymentMethodProtector to use. * @param PayForOrderProtector $pay_for_order_protector The instance of PayForOrderProtector to use. + * @param Compat\PayPalCompat $paypal_compat The instance of PayPalCompat to use. */ final public function init( BlockedSessionNotice $blocked_session_notice, @@ -123,7 +131,8 @@ final public function init( BlocksCheckoutProtector $blocks_checkout_protector, ShortcodeCheckoutProtector $shortcode_checkout_protector, AddPaymentMethodProtector $add_payment_method_protector, - PayForOrderProtector $pay_for_order_protector + PayForOrderProtector $pay_for_order_protector, + Compat\PayPalCompat $paypal_compat ): void { $this->blocked_session_notice = $blocked_session_notice; $this->blackbox_script_handler = $blackbox_script_handler; @@ -135,6 +144,7 @@ final public function init( $this->shortcode_checkout_protector = $shortcode_checkout_protector; $this->add_payment_method_protector = $add_payment_method_protector; $this->pay_for_order_protector = $pay_for_order_protector; + $this->paypal_compat = $paypal_compat; } /** @@ -158,6 +168,7 @@ public function on_init(): void { $this->cart_event_tracker->register(); $this->checkout_event_tracker->register(); $this->payment_method_event_tracker->register(); + $this->paypal_compat->register(); } /** diff --git a/tests/js/paypal-express.test.js b/tests/js/paypal-express.test.js new file mode 100644 index 0000000..85f1c07 --- /dev/null +++ b/tests/js/paypal-express.test.js @@ -0,0 +1,138 @@ +/** + * @jest-environment jsdom + */ + +/** + * Tests for paypal-express.js — Fetch interceptor for PayPal CreateOrder AJAX. + * + * paypal-express.js is an IIFE. We test it by setting up global mocks, + * requiring the file (which executes the IIFE), and asserting on mocks. + * + * @package WooCommerce\FraudProtection + */ + +let mockAcquireSessionId; +let originalFetch; +let fetchCalls; + +beforeEach( () => { + delete window.wcFraudProtection; + + fetchCalls = []; + originalFetch = jest.fn( ( resource, init ) => { + fetchCalls.push( { resource, init } ); + return Promise.resolve( { ok: true, json: () => Promise.resolve( {} ) } ); + } ); + window.fetch = originalFetch; + + mockAcquireSessionId = jest.fn( () => Promise.resolve( 'test-session-abc' ) ); +} ); + +function setupAndLoad( config ) { + window.wcFraudProtection = { + config: config || { sessionIdField: 'wc_fraud_protection_session_id' }, + acquireSessionId: mockAcquireSessionId, + reset: jest.fn(), + }; + + jest.isolateModules( () => { + require( '../../assets/js/paypal-express' ); + } ); +} + +describe( 'paypal-express fetch interceptor', () => { + describe( 'interception', () => { + it( 'intercepts ppc-create-order requests and injects session_id', async () => { + setupAndLoad(); + + const body = JSON.stringify( { nonce: 'abc', context: 'product' } ); + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { body } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + expect( fetchCalls ).toHaveLength( 1 ); + + const sentBody = JSON.parse( fetchCalls[ 0 ].init.body ); + expect( sentBody.wc_fraud_protection_session_id ).toBe( 'test-session-abc' ); + expect( sentBody.nonce ).toBe( 'abc' ); + } ); + + it( 'does not intercept non-PayPal fetch calls', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/wp-json/wc/store/v1/checkout', { body: '{}' } ); + + expect( mockAcquireSessionId ).not.toHaveBeenCalled(); + expect( fetchCalls ).toHaveLength( 1 ); + expect( fetchCalls[ 0 ].init.body ).toBe( '{}' ); + } ); + + it( 'handles Request objects with url property', async () => { + setupAndLoad(); + + const request = { url: 'https://store.test/?wc-ajax=ppc-create-order' }; + await window.fetch( request, { body: JSON.stringify( {} ) } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'fail-open', () => { + it( 'sends request without session_id when body is not valid JSON', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { body: 'not-json' } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + expect( fetchCalls ).toHaveLength( 1 ); + // Body is unchanged since JSON.parse failed. + expect( fetchCalls[ 0 ].init.body ).toBe( 'not-json' ); + } ); + + it( 'does nothing when wcFraudProtection is not available', async () => { + // Don't call setupAndLoad — wcFraudProtection is not set. + const savedFetch = window.fetch; + + jest.isolateModules( () => { + require( '../../assets/js/paypal-express' ); + } ); + + // fetch should not have been replaced. + expect( window.fetch ).toBe( savedFetch ); + } ); + } ); + + describe( 'reset', () => { + it( 'calls reset after intercepted CreateOrder fetch returns', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { + body: JSON.stringify( {} ), + } ); + + expect( window.wcFraudProtection.reset ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'does not call reset for non-PayPal fetch calls', async () => { + setupAndLoad(); + + await window.fetch( 'https://store.test/wp-json/wc/store/v1/checkout', { + body: '{}', + } ); + + expect( window.wcFraudProtection.reset ).not.toHaveBeenCalled(); + } ); + + it( 'calls reset even when fetch rejects', async () => { + originalFetch.mockImplementationOnce( () => Promise.reject( new Error( 'Network error' ) ) ); + setupAndLoad(); + + await expect( + window.fetch( 'https://store.test/?wc-ajax=ppc-create-order', { + body: JSON.stringify( {} ), + } ) + ).rejects.toThrow( 'Network error' ); + + expect( window.wcFraudProtection.reset ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/tests/php/src/Compat/PayPalCompatTest.php b/tests/php/src/Compat/PayPalCompatTest.php new file mode 100644 index 0000000..3f64a44 --- /dev/null +++ b/tests/php/src/Compat/PayPalCompatTest.php @@ -0,0 +1,232 @@ +session_verifier = $this->createMock( SessionVerifier::class ); + $this->blocked_session_notice = $this->createMock( BlockedSessionNotice::class ); + + $this->blocked_session_notice + ->method( 'get_message_plaintext' ) + ->willReturn( 'We are unable to process this request online. Please contact support (test@example.com) to complete your purchase.' ); + + $this->sut = new PayPalCompat(); + $this->sut->init( + $this->session_verifier, + $this->blocked_session_notice + ); + } + + /** + * Tear down after each test. + */ + public function tearDown(): void { + remove_all_filters( 'wp_doing_ajax' ); + remove_all_filters( 'wp_die_ajax_handler' ); + remove_all_filters( 'woocommerce_fraud_protection_enqueue_blackbox_scripts' ); + remove_all_actions( 'woocommerce_paypal_payments_create_order_request_started' ); + remove_all_actions( 'wp_enqueue_scripts' ); + wp_dequeue_script( 'wc-fraud-protection-blackbox-init' ); + wp_dequeue_script( 'wc-fraud-protection-paypal-express' ); + + parent::tearDown(); + } + + /* + |-------------------------------------------------------------------------- + | register() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox register() hooks the create_order action, enqueue filter, and script action. + */ + public function test_register_hooks(): void { + $this->sut->register(); + + $this->assertNotFalse( + has_action( 'woocommerce_paypal_payments_create_order_request_started', array( $this->sut, 'verify_and_block_create_order' ) ), + 'create_order_request_started action should be registered' + ); + $this->assertNotFalse( + has_filter( 'woocommerce_fraud_protection_enqueue_blackbox_scripts', array( $this->sut, 'should_enqueue_blackbox' ) ), + 'enqueue_blackbox_scripts filter should be registered' + ); + $this->assertNotFalse( + has_action( 'wp_enqueue_scripts', array( $this->sut, 'enqueue_paypal_script' ) ), + 'wp_enqueue_scripts action should be registered' + ); + } + + /* + |-------------------------------------------------------------------------- + | verify_and_block_create_order() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox verify_and_block_create_order() extracts session_id from data and calls verify_session — allows on ALLOW. + */ + public function test_verify_allows_on_allow_decision(): void { + $data = array( BlackboxScriptHandler::SESSION_ID_FIELD => 'test-session-abc' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( 'test-session-abc', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + // Should return normally without terminating. + $this->sut->verify_and_block_create_order( $data ); + } + + /** + * @testdox verify_and_block_create_order() sends JSON error with 403 on BLOCK decision. + */ + public function test_verify_blocks_on_block_decision(): void { + $data = array( BlackboxScriptHandler::SESSION_ID_FIELD => 'test-session-blocked' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( 'test-session-blocked', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_BLOCK ); + + $this->blocked_session_notice + ->expects( $this->once() ) + ->method( 'get_message_plaintext' ) + ->with( 'purchase' ); + + // wp_send_json_error calls wp_die() only when wp_doing_ajax() is true, + // otherwise it calls die() directly. Force AJAX context and override + // the AJAX die handler to throw an exception we can catch. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( + 'wp_die_ajax_handler', + function () { + return function () { + throw new \WPDieException( 'wp_die called' ); + }; + } + ); + + // Buffer output to capture the JSON echoed by wp_send_json_error. + ob_start(); + try { + $this->sut->verify_and_block_create_order( $data ); + $this->fail( 'Expected WPDieException was not thrown' ); + } catch ( \WPDieException $e ) { + $json = (string) ob_get_clean(); + $body = json_decode( $json, true ); + $this->assertFalse( $body['success'] ); + $this->assertStringContainsString( 'unable to process', $body['data']['message'] ); + } + } + + /** + * @testdox verify_and_block_create_order() calls verify with empty session_id when field is missing. + */ + public function test_verify_with_missing_session_id(): void { + $data = array( 'context' => 'product' ); + + $this->session_verifier + ->expects( $this->once() ) + ->method( 'verify_session' ) + ->with( '', 'paypal_express_order_creation', 0, $data ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->sut->verify_and_block_create_order( $data ); + } + + /* + |-------------------------------------------------------------------------- + | should_enqueue_blackbox() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox should_enqueue_blackbox() returns true when already set to enqueue. + */ + public function test_should_enqueue_blackbox_passthrough_when_already_true(): void { + $this->assertTrue( $this->sut->should_enqueue_blackbox( true ) ); + } + + /** + * @testdox should_enqueue_blackbox() returns false when PayPal is not available. + */ + public function test_should_enqueue_blackbox_false_when_no_paypal(): void { + // No PayPal gateways registered by default. + $this->assertFalse( $this->sut->should_enqueue_blackbox( false ) ); + } + + /* + |-------------------------------------------------------------------------- + | enqueue_paypal_script() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox enqueue_paypal_script() enqueues when blackbox-init is already enqueued. + */ + public function test_enqueue_paypal_script_when_blackbox_init_enqueued(): void { + wp_enqueue_script( 'wc-fraud-protection-blackbox-init', 'https://example.com/blackbox-init.js', array(), '1.0', true ); + + $this->sut->enqueue_paypal_script(); + + $this->assertTrue( wp_script_is( 'wc-fraud-protection-paypal-express', 'enqueued' ) ); + } + + /** + * @testdox enqueue_paypal_script() does not enqueue when blackbox-init is absent. + */ + public function test_enqueue_paypal_script_skips_when_blackbox_init_absent(): void { + $this->sut->enqueue_paypal_script(); + + $this->assertFalse( wp_script_is( 'wc-fraud-protection-paypal-express', 'enqueued' ) ); + } +} diff --git a/woocommerce-fraud-protection.php b/woocommerce-fraud-protection.php index 77daf3d..743050e 100644 --- a/woocommerce-fraud-protection.php +++ b/woocommerce-fraud-protection.php @@ -41,6 +41,7 @@ require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/ShortcodeCheckoutProtector.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/AddPaymentMethodProtector.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/PayForOrderProtector.php'; +require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/Compat/PayPalCompat.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/SessionBlockingHandler.php'; require_once WC_FRAUD_PROTECTION_PLUGIN_DIR . '/src/FraudProtectionController.php'; @@ -91,6 +92,9 @@ function () { $square_compat = new \Automattic\WooCommerce\FraudProtection\Compat\SquarePaymentDataCompat(); $square_compat->register(); + $paypal_compat = new \Automattic\WooCommerce\FraudProtection\Compat\PayPalCompat(); + $paypal_compat->init( $session_verifier, $blocked_notice ); + $blocks_checkout_protector = new \Automattic\WooCommerce\FraudProtection\BlocksCheckoutProtector(); $blocks_checkout_protector->init( $session_verifier, $blocked_notice ); @@ -115,7 +119,8 @@ function () { $blocks_checkout_protector, $shortcode_checkout_protector, $add_payment_method_protector, - $pay_for_order_protector + $pay_for_order_protector, + $paypal_compat ); $controller->register(); } From 781afe9032d5a619cd6f8b51eba5c849a30dee3f Mon Sep 17 00:00:00 2001 From: Luiz Reis Date: Thu, 5 Mar 2026 12:58:36 -0300 Subject: [PATCH 2/3] Fraud Protection: Skip redundant verification for PayPal express flows PayPal checkout flows trigger double verification: PayPalCompat verifies during ppc-create-order (with session_id), then after approval, the standard protector fires again with an empty session_id (PayPal submits the checkout request directly, bypassing checkout JS). Add a `woocommerce_fraud_protection_should_verify_session` filter in SessionVerifier so extensions can skip redundant verify calls. PayPalCompat hooks this filter to skip verification when the payment method is a ppcp-* gateway AND either: - An approved PayPal order exists in the WC session (post-approval flow already verified at CreateOrder), or - The current request is ppc-create-order itself (PayPal's form validation fires the shortcode protector before our verify runs). Regular checkout with PayPal (e.g. Blocks "Place Order" without prior approval) is not skipped. --- assets/js/paypal-express.js | 3 +- src/Compat/PayPalCompat.php | 144 ++++++++++++++- src/SessionVerifier.php | 32 ++++ tests/php/src/Compat/PayPalCompatTest.php | 202 ++++++++++++++++++++-- tests/php/src/SessionVerifierTest.php | 113 ++++++++++++ 5 files changed, 473 insertions(+), 21 deletions(-) diff --git a/assets/js/paypal-express.js b/assets/js/paypal-express.js index 6a31efe..d217a1f 100644 --- a/assets/js/paypal-express.js +++ b/assets/js/paypal-express.js @@ -32,8 +32,7 @@ try { const body = JSON.parse( init.body ); body[ fp.config.sessionIdField ] = sessionId; - const jsonBody = JSON.stringify( body ); - init.body = jsonBody; + init.body = JSON.stringify( body ); } catch ( e ) { // Fail-open: send the request without session ID. } diff --git a/src/Compat/PayPalCompat.php b/src/Compat/PayPalCompat.php index cd0b62d..cccdac8 100644 --- a/src/Compat/PayPalCompat.php +++ b/src/Compat/PayPalCompat.php @@ -10,7 +10,6 @@ use Automattic\WooCommerce\FraudProtection\ApiClient; use Automattic\WooCommerce\FraudProtection\BlackboxScriptHandler; use Automattic\WooCommerce\FraudProtection\BlockedSessionNotice; -use Automattic\WooCommerce\FraudProtection\FraudProtectionController; use Automattic\WooCommerce\FraudProtection\SessionVerifier; defined( 'ABSPATH' ) || exit; @@ -22,9 +21,6 @@ * WC checkout pipeline. This class hooks into PayPal's CreateOrder AJAX endpoint * to verify sessions before PayPal order creation. * - * Payment data resolution (card details, wallet/payer identity) is handled - * separately by PayPalPaymentDataCompat. - * * The JS fetch interceptor resets Blackbox after the CreateOrder fetch returns, * so subsequent payment attempts (retry, different method) get a fresh session. * @@ -42,6 +38,11 @@ class PayPalCompat { */ private const PAYPAL_GATEWAY_PREFIX = 'ppcp-'; + /** + * WC session key for the Blackbox session ID verified during ppc-create-order. + */ + private const VERIFIED_SESSION_ID_KEY = '_fraud_protection_paypal_verified_session_id'; + /** * Session verifier instance. * @@ -83,6 +84,7 @@ public function register(): void { add_action( 'woocommerce_paypal_payments_create_order_request_started', array( $this, 'verify_and_block_create_order' ) ); add_filter( 'woocommerce_fraud_protection_enqueue_blackbox_scripts', array( $this, 'should_enqueue_blackbox' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_paypal_script' ), 20 ); + add_filter( 'woocommerce_fraud_protection_skip_session_verify', array( $this, 'skip_default_verify_for_paypal_express' ), 10, 4 ); } /** @@ -105,6 +107,15 @@ public function verify_and_block_create_order( array $data ): void { $decision = $this->session_verifier->verify_session( $session_id, self::ORDER_CREATION_SOURCE, 0, $data ); + // Store the verified session ID so standard protectors can skip + // redundant verification for the same Blackbox session (e.g. when + // BlocksCheckoutProtector fires after ppc-create-order for card flows). + // Stored regardless of decision: Blackbox sessions are single-use, so + // re-verifying the same ID would fail rather than produce a fresh verdict. + if ( '' !== $session_id && function_exists( 'WC' ) && WC()->session ) { + WC()->session->set( self::VERIFIED_SESSION_ID_KEY, $session_id ); + } + if ( ApiClient::DECISION_BLOCK === $decision ) { wp_send_json_error( array( 'message' => $this->blocked_session_notice->get_message_plaintext( 'purchase' ) ), @@ -165,6 +176,131 @@ public function enqueue_paypal_script(): void { ); } + /** + * Skip redundant verification for PayPal flows handled by PayPalCompat. + * + * PayPal smart-button flows go through ppc-create-order where PayPalCompat + * verifies the session. Standard protectors should skip when: + * + * 1. The payment method is a PayPal gateway (ppcp-* prefix), AND + * 2. Any of: + * a. The current request IS ppc-create-order — PayPal's + * CreateOrderEndpoint fires woocommerce_checkout_process during + * form validation before our verify action runs, so the shortcode + * protector should defer to PayPalCompat. + * b. The Blackbox session ID matches one already verified during + * ppc-create-order — the standard protector captured the same + * session ID before ppc-create-order ran (e.g. card flows on + * blocks checkout where blocks-checkout.js acquires the ID first). + * c. An approved PayPal order exists in the WC session — the request + * is completing an express flow already verified at CreateOrder + * (ppc-approve-order stored the order after approval). + * + * Flows that do NOT go through ppc-create-order (Blocks "Place Order" + * with ppcp-gateway server-side redirect, APM gateways) are NOT skipped. + * + * @internal + * + * @param bool $skip Whether to skip verification. + * @param string $source Source identifier. + * @param array $request_data Request data with payment_method, payment_data, etc. + * @param string $session_id The Blackbox session ID being verified. + * @return bool + */ + public function skip_default_verify_for_paypal_express( bool $skip, string $source, array $request_data, string $session_id ): bool { + if ( $skip ) { + return true; + } + + // Don't skip PayPalCompat's own verification sources. + if ( self::ORDER_CREATION_SOURCE === $source ) { + return false; + } + + $payment_method = (string) ( $request_data['payment_method'] ?? '' ); + + // Not a PayPal gateway — nothing for this filter to do. + if ( ! $this->is_paypal_gateway( $payment_method ) ) { + return false; + } + + // During ppc-create-order: early validation fires woocommerce_checkout_process + // before PayPalCompat verifies. Skip — PayPalCompat handles it next. + if ( $this->is_paypal_create_order_request() ) { + return true; + } + + // Same Blackbox session already verified during ppc-create-order in this + // payment flow (e.g. card fields on blocks checkout where blocks-checkout.js + // captured the session ID before ppc-create-order ran). + if ( $this->was_session_verified_at_create_order( $session_id ) ) { + return true; + } + + // After express approval: PayPal order in session means ppc-create-order + // already verified (Blackbox was reset since, so session IDs won't match). + if ( $this->has_paypal_order_in_session() ) { + return true; + } + + // All other ppcp-* flows (Blocks "Place Order" with ppcp-gateway, APMs): don't skip. + return false; + } + + /** + * Check if an approved PayPal order exists in the WC session. + * + * PayPal Payments stores the approved order in the 'ppcp' session key + * after ppc-create-order and ppc-approve-order. Its presence indicates + * the checkout is completing a flow where ppc-create-order already ran. + * + * @return bool + */ + private function has_paypal_order_in_session(): bool { + if ( ! function_exists( 'WC' ) || ! WC()->session ) { + return false; + } + + $ppcp_session = WC()->session->get( 'ppcp' ); + + return is_array( $ppcp_session ) && ! empty( $ppcp_session['order'] ); + } + + /** + * Check if a Blackbox session ID was already verified during ppc-create-order. + * + * Smart-button flows (cards, wallets, express) call ppc-create-order where + * PayPalCompat verifies the session. For flows where the standard protector + * captures the same Blackbox session ID before ppc-create-order runs (e.g. + * blocks-checkout.js acquires the ID, then PayPal's createOrder callback + * fires ppc-create-order), the session IDs match and we can skip. + * + * @param string $session_id The session ID from the current verify call. + * @return bool + */ + private function was_session_verified_at_create_order( string $session_id ): bool { + if ( '' === $session_id || ! function_exists( 'WC' ) || ! WC()->session ) { + return false; + } + + return WC()->session->get( self::VERIFIED_SESSION_ID_KEY, '' ) === $session_id; + } + + /** + * Check if the current request is a PayPal ppc-create-order AJAX request. + * + * During ppc-create-order, PayPal's CreateOrderEndpoint may fire + * woocommerce_checkout_process (via validate_form()) before our + * verify_and_block_create_order action fires. The standard protector + * should skip because PayPalCompat will verify later in the same request. + * + * @return bool + */ + private function is_paypal_create_order_request(): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking the endpoint name, not processing data. + return isset( $_GET['wc-ajax'] ) && 'ppc-create-order' === $_GET['wc-ajax']; + } + /** * Check if a gateway ID belongs to PayPal Payments. * diff --git a/src/SessionVerifier.php b/src/SessionVerifier.php index 55b9466..8e44e8d 100644 --- a/src/SessionVerifier.php +++ b/src/SessionVerifier.php @@ -89,6 +89,38 @@ final public function init( * @return string The final decision: 'allow' or 'block'. */ public function verify_session( string $session_id, string $source, int $order_id = 0, array $request_data = array() ): string { + try { + /** + * Filters whether to skip session verification. + * + * Extensions that handle their own verification (e.g. PayPal express + * checkout via PayPalCompat) can return true to skip the redundant + * verify call from standard protectors. + * + * Fail-open: truthy values skip verification (session is allowed). + * + * @param bool $skip Whether to skip verification. Default false. + * @param string $source Source identifier (e.g. 'blocks_checkout'). + * @param array $request_data Request data with payment_method, payment_data, etc. + * @param string $session_id The Blackbox session ID being verified. + */ + $skip = (bool) apply_filters( 'woocommerce_fraud_protection_skip_session_verify', false, $source, $request_data, $session_id ); + + if ( $skip ) { + FraudProtectionController::log( + 'info', + sprintf( 'Session verification skipped by `woocommerce_fraud_protection_skip_session_verify` filter for source: %s', $source ) + ); + return ApiClient::DECISION_ALLOW; + } + } catch ( \Throwable $e ) { + FraudProtectionController::log( + 'warning', + '`woocommerce_fraud_protection_skip_session_verify` filter threw: ' . $e->getMessage(), + array( 'exception' => $e ) + ); + } + // Resolve payment data (fail-open). $payment_data = null; try { diff --git a/tests/php/src/Compat/PayPalCompatTest.php b/tests/php/src/Compat/PayPalCompatTest.php index 3f64a44..6f3f6d0 100644 --- a/tests/php/src/Compat/PayPalCompatTest.php +++ b/tests/php/src/Compat/PayPalCompatTest.php @@ -69,11 +69,19 @@ public function tearDown(): void { remove_all_filters( 'wp_doing_ajax' ); remove_all_filters( 'wp_die_ajax_handler' ); remove_all_filters( 'woocommerce_fraud_protection_enqueue_blackbox_scripts' ); + remove_all_filters( 'woocommerce_fraud_protection_skip_session_verify' ); remove_all_actions( 'woocommerce_paypal_payments_create_order_request_started' ); remove_all_actions( 'wp_enqueue_scripts' ); wp_dequeue_script( 'wc-fraud-protection-blackbox-init' ); wp_dequeue_script( 'wc-fraud-protection-paypal-express' ); + if ( WC()->session ) { + WC()->session->set( 'ppcp', null ); + WC()->session->set( '_fraud_protection_paypal_verified_session_id', null ); + } + + unset( $_GET['wc-ajax'] ); + parent::tearDown(); } @@ -101,6 +109,10 @@ public function test_register_hooks(): void { has_action( 'wp_enqueue_scripts', array( $this->sut, 'enqueue_paypal_script' ) ), 'wp_enqueue_scripts action should be registered' ); + $this->assertNotFalse( + has_filter( 'woocommerce_fraud_protection_skip_session_verify', array( $this->sut, 'skip_default_verify_for_paypal_express' ) ), + 'should_verify_session filter should be registered' + ); } /* @@ -123,6 +135,23 @@ public function test_verify_allows_on_allow_decision(): void { // Should return normally without terminating. $this->sut->verify_and_block_create_order( $data ); + + $this->assertSame( 'test-session-abc', WC()->session->get( '_fraud_protection_paypal_verified_session_id' ) ); + } + + /** + * @testdox verify_and_block_create_order() does not store empty session ID in WC session. + */ + public function test_verify_does_not_store_empty_session_id(): void { + $data = array( 'context' => 'product' ); + + $this->session_verifier + ->method( 'verify_session' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->sut->verify_and_block_create_order( $data ); + + $this->assertNull( WC()->session->get( '_fraud_protection_paypal_verified_session_id' ) ); } /** @@ -142,30 +171,23 @@ public function test_verify_blocks_on_block_decision(): void { ->method( 'get_message_plaintext' ) ->with( 'purchase' ); - // wp_send_json_error calls wp_die() only when wp_doing_ajax() is true, - // otherwise it calls die() directly. Force AJAX context and override - // the AJAX die handler to throw an exception we can catch. + // wp_send_json_error() echoes JSON then calls wp_die(). Force AJAX + // context (otherwise it calls bare die()) and override the die + // handler to throw a catchable exception. add_filter( 'wp_doing_ajax', '__return_true' ); add_filter( 'wp_die_ajax_handler', function () { return function () { - throw new \WPDieException( 'wp_die called' ); + throw new \WPDieException(); }; } ); - // Buffer output to capture the JSON echoed by wp_send_json_error. - ob_start(); - try { - $this->sut->verify_and_block_create_order( $data ); - $this->fail( 'Expected WPDieException was not thrown' ); - } catch ( \WPDieException $e ) { - $json = (string) ob_get_clean(); - $body = json_decode( $json, true ); - $this->assertFalse( $body['success'] ); - $this->assertStringContainsString( 'unable to process', $body['data']['message'] ); - } + $this->expectException( \WPDieException::class ); + $this->expectOutputRegex( '/"success":false.*unable to process this request/' ); + + $this->sut->verify_and_block_create_order( $data ); } /** @@ -229,4 +251,154 @@ public function test_enqueue_paypal_script_skips_when_blackbox_init_absent(): vo $this->assertFalse( wp_script_is( 'wc-fraud-protection-paypal-express', 'enqueued' ) ); } + + /* + |-------------------------------------------------------------------------- + | skip_default_verify_for_paypal_express() Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) when PayPal gateway has approved order in session. + */ + public function test_skip_verify_skips_for_paypal_with_approved_order(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $gateways = array( 'ppcp-gateway', 'ppcp-credit-card-gateway', 'ppcp-applepay', 'ppcp-googlepay', 'ppcp-axo-gateway' ); + + foreach ( $gateways as $gateway ) { + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => $gateway ), + 'some-session-id' + ); + + $this->assertTrue( $result, "Expected true (skip) for gateway: $gateway" ); + } + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) during ppc-create-order request. + */ + public function test_skip_verify_skips_during_create_order_request(): void { + $_GET['wc-ajax'] = 'ppc-create-order'; + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'shortcode_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertTrue( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns true (skip) when session ID matches one verified at ppc-create-order. + */ + public function test_skip_verify_skips_when_session_id_matches_verified(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', 'test-session-abc' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-credit-card-gateway' ), + 'test-session-abc' + ); + + $this->assertTrue( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when both session IDs are empty (no accidental blank match). + */ + public function test_skip_verify_does_not_skip_when_both_session_ids_are_empty(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', '' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + '' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when session ID does not match (different flow). + */ + public function test_skip_verify_does_not_skip_when_session_id_does_not_match(): void { + WC()->session->set( '_fraud_protection_paypal_verified_session_id', 'old-session' ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-ideal' ), + 'new-session' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) for PayPal gateway without approved order or create-order request. + */ + public function test_skip_verify_does_not_skip_for_paypal_without_approved_order(): void { + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) when payment method is not PayPal. + */ + public function test_skip_verify_does_not_skip_for_non_paypal_gateway(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'blocks_checkout', + array( 'payment_method' => 'stripe' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() returns false (don't skip) for its own source even with approved order. + */ + public function test_skip_verify_does_not_skip_own_source(): void { + WC()->session->set( 'ppcp', array( 'order' => new \stdClass() ) ); + + $result = $this->sut->skip_default_verify_for_paypal_express( + false, + 'paypal_express_order_creation', + array( 'payment_method' => 'ppcp-gateway' ), + 'some-session-id' + ); + + $this->assertFalse( $result ); + } + + /** + * @testdox skip_default_verify_for_paypal_express() passes through true (skip) from an earlier filter. + */ + public function test_skip_verify_respects_true_from_earlier_filter(): void { + $result = $this->sut->skip_default_verify_for_paypal_express( + true, + 'blocks_checkout', + array( 'payment_method' => 'stripe' ), + 'some-session-id' + ); + + $this->assertTrue( $result ); + } } diff --git a/tests/php/src/SessionVerifierTest.php b/tests/php/src/SessionVerifierTest.php index b376a51..b38de29 100644 --- a/tests/php/src/SessionVerifierTest.php +++ b/tests/php/src/SessionVerifierTest.php @@ -81,6 +81,14 @@ public function setUp(): void { ); } + /** + * Tear down after each test. + */ + public function tearDown(): void { + remove_all_filters( 'woocommerce_fraud_protection_skip_session_verify' ); + parent::tearDown(); + } + /* |-------------------------------------------------------------------------- | Pipeline Tests @@ -324,4 +332,109 @@ public function test_verify_session_fails_open_when_decision_handler_throws(): v ); } + /* + |-------------------------------------------------------------------------- + | Should Verify Filter Tests + |-------------------------------------------------------------------------- + */ + + /** + * @testdox verify_session() skips verification and returns ALLOW when filter returns true. + */ + public function test_verify_session_skips_when_filter_returns_true(): void { + add_filter( 'woocommerce_fraud_protection_skip_session_verify', '__return_true' ); + + $this->api_client + ->expects( $this->never() ) + ->method( 'verify' ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + $this->assertLogged( 'info', 'Session verification skipped by `woocommerce_fraud_protection_skip_session_verify` filter for source: blocks_checkout' ); + } + + /** + * @testdox verify_session() proceeds normally when filter returns false. + */ + public function test_verify_session_proceeds_when_filter_returns_false(): void { + add_filter( 'woocommerce_fraud_protection_skip_session_verify', '__return_false' ); + + $this->data_collector + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + } + + /** + * @testdox verify_session() proceeds normally when filter returns non-bool falsy value. + */ + public function test_verify_session_proceeds_when_filter_returns_non_bool(): void { + add_filter( + 'woocommerce_fraud_protection_skip_session_verify', + function () { + return null; + } + ); + + $this->data_collector + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + } + + /** + * @testdox verify_session() proceeds normally when filter callback throws (fail-open). + */ + public function test_verify_session_proceeds_when_filter_throws(): void { + add_filter( // @phpstan-ignore return.missing + 'woocommerce_fraud_protection_skip_session_verify', + function () { + throw new \RuntimeException( 'Filter exploded' ); + } + ); + + $this->data_collector // @phpstan-ignore deadCode.unreachable + ->method( 'get_collected_data' ) + ->willReturn( array() ); + + $this->api_client + ->expects( $this->once() ) + ->method( 'verify' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $this->decision_handler + ->method( 'apply_decision' ) + ->willReturn( ApiClient::DECISION_ALLOW ); + + $result = $this->sut->verify_session( 'test-session', 'blocks_checkout' ); + + $this->assertSame( ApiClient::DECISION_ALLOW, $result ); + $this->assertLogged( 'warning', '`woocommerce_fraud_protection_skip_session_verify` filter threw: Filter exploded' ); + } + } From 2b0d10f52f0eb927b986313b3f37b587246675ea Mon Sep 17 00:00:00 2001 From: Luiz Reis Date: Fri, 13 Mar 2026 14:16:46 -0300 Subject: [PATCH 3/3] Fraud Protection: Handle URL and Request objects in fetch interceptor The fetch API accepts string, URL, or Request objects as the resource parameter. Use instanceof Request for Request objects and String() for URL objects to correctly extract the URL for interception matching. --- assets/js/paypal-express.js | 4 +++- tests/js/paypal-express.test.js | 11 ++++++++++- tests/js/setup.js | 7 +++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/assets/js/paypal-express.js b/assets/js/paypal-express.js index d217a1f..0767e30 100644 --- a/assets/js/paypal-express.js +++ b/assets/js/paypal-express.js @@ -20,7 +20,9 @@ window.fetch = async function ( resource, init ) { init = init || {}; - const url = typeof resource === 'string' ? resource : resource.url; + // fetch() accepts string, URL, or Request objects. + const url = + resource instanceof Request ? resource.url : String( resource ); if ( ! url || url.indexOf( 'ppc-create-order' ) === -1 ) { return originalFetch.call( this, resource, init ); diff --git a/tests/js/paypal-express.test.js b/tests/js/paypal-express.test.js index 85f1c07..35f88c7 100644 --- a/tests/js/paypal-express.test.js +++ b/tests/js/paypal-express.test.js @@ -69,11 +69,20 @@ describe( 'paypal-express fetch interceptor', () => { it( 'handles Request objects with url property', async () => { setupAndLoad(); - const request = { url: 'https://store.test/?wc-ajax=ppc-create-order' }; + const request = new Request( 'https://store.test/?wc-ajax=ppc-create-order' ); await window.fetch( request, { body: JSON.stringify( {} ) } ); expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'handles URL objects', async () => { + setupAndLoad(); + + const url = new URL( 'https://store.test/?wc-ajax=ppc-create-order' ); + await window.fetch( url, { body: JSON.stringify( {} ) } ); + + expect( mockAcquireSessionId ).toHaveBeenCalledTimes( 1 ); + } ); } ); describe( 'fail-open', () => { diff --git a/tests/js/setup.js b/tests/js/setup.js index a04e0a7..d2e7b30 100644 --- a/tests/js/setup.js +++ b/tests/js/setup.js @@ -1,3 +1,10 @@ +// JSDOM does not provide Fetch API globals. Stub Request for instanceof checks. +global.Request = class Request { + constructor( input ) { + this.url = typeof input === 'string' ? input : String( input ); + } +}; + // Stub HTMLFormElement.prototype.submit to prevent jsdom "Not implemented" errors. // Individual tests can override form.submit with their own spy when they need to assert on it. if ( typeof HTMLFormElement !== 'undefined' ) {