Skip to content

Commit 17868ba

Browse files
committed
Fraud Protection: Add Blackbox integration for Subscriptions change-payment-method flow
WooCommerce Subscriptions hijacks the pay-for-order page with its own handler, bypassing WC Core's `woocommerce_before_pay_action` hook. This adds a compat layer that hooks the Subscriptions action fired after nonce verification to verify the session and block on BLOCK decisions via redirect + exit, preventing both `update_payment_method()` and `process_payment()` from running.
1 parent 67eae73 commit 17868ba

File tree

3 files changed

+443
-0
lines changed

3 files changed

+443
-0
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
/**
3+
* SubscriptionsChangePaymentCompat class file.
4+
*/
5+
6+
declare( strict_types=1 );
7+
8+
namespace Automattic\WooCommerce\FraudProtection\Compat;
9+
10+
use Automattic\WooCommerce\FraudProtection\ApiClient;
11+
use Automattic\WooCommerce\FraudProtection\BlockedSessionNotice;
12+
use Automattic\WooCommerce\FraudProtection\ClassicFormDataExtractionTrait;
13+
use Automattic\WooCommerce\FraudProtection\FraudProtectionController;
14+
use Automattic\WooCommerce\FraudProtection\SessionVerifier;
15+
16+
defined( 'ABSPATH' ) || exit;
17+
18+
/**
19+
* Integrates Blackbox fraud protection into the WooCommerce Subscriptions
20+
* "Change Payment Method" flow.
21+
*
22+
* WooCommerce Subscriptions hijacks the pay-for-order page with its own
23+
* request handler, so WC Core's `woocommerce_before_pay_action` never fires
24+
* and the PayForOrderProtector server-side hook is bypassed. This compat
25+
* layer hooks the Subscriptions action that fires after nonce verification
26+
* to verify the session and block on BLOCK decisions.
27+
*
28+
* The JS side is already handled: PayForOrderProtector enqueues pay-for-order.js
29+
* on `is_checkout_pay_page()`, which matches the change-payment page. The script
30+
* gates the `#order_review` form submission to inject the Blackbox session ID.
31+
*
32+
* On BLOCK, the request is stopped entirely via redirect + exit, preventing both
33+
* `update_payment_method()` and `process_payment()` from running.
34+
*
35+
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
36+
*
37+
* @internal
38+
*/
39+
class SubscriptionsChangePaymentCompat {
40+
41+
use ClassicFormDataExtractionTrait;
42+
43+
/**
44+
* Source identifier for verify requests from this compat layer.
45+
*/
46+
private const SOURCE = 'subscriptions_change_payment_method';
47+
48+
/**
49+
* Session verifier instance.
50+
*
51+
* @var SessionVerifier
52+
*/
53+
private SessionVerifier $session_verifier;
54+
55+
/**
56+
* Blocked session notice instance.
57+
*
58+
* @var BlockedSessionNotice
59+
*/
60+
private BlockedSessionNotice $blocked_session_notice;
61+
62+
/**
63+
* Initialize with dependencies.
64+
*
65+
* @internal
66+
*
67+
* @param SessionVerifier $session_verifier The session verifier instance.
68+
* @param BlockedSessionNotice $blocked_session_notice The blocked session notice instance.
69+
*/
70+
final public function init(
71+
SessionVerifier $session_verifier,
72+
BlockedSessionNotice $blocked_session_notice
73+
): void {
74+
$this->session_verifier = $session_verifier;
75+
$this->blocked_session_notice = $blocked_session_notice;
76+
}
77+
78+
/**
79+
* Register hooks for change-payment-method fraud protection.
80+
*
81+
* The hook only fires when WooCommerce Subscriptions is active and
82+
* a customer submits the change-payment-method form.
83+
*
84+
* @return void
85+
*/
86+
public function register(): void {
87+
add_action( 'woocommerce_subscription_change_payment_method_via_pay_shortcode', array( $this, 'verify_and_block' ) );
88+
}
89+
90+
/**
91+
* Verify the session with Blackbox and block if needed.
92+
*
93+
* Called during the `woocommerce_subscription_change_payment_method_via_pay_shortcode`
94+
* action, which fires AFTER nonce verification but BEFORE `update_payment_method()`
95+
* and `process_payment()`.
96+
*
97+
* On BLOCK, stops the request entirely via redirect + exit. This prevents the
98+
* Subscriptions handler from reaching `update_payment_method()` (which saves to DB
99+
* and triggers gateway cancellation) and `process_payment()`.
100+
*
101+
* Fail-open: SessionVerifier handles all internal errors and returns ALLOW.
102+
*
103+
* @internal
104+
*
105+
* @param \WC_Order $subscription The subscription being updated (WC_Subscription extends WC_Order).
106+
* @return void
107+
*/
108+
public function verify_and_block( \WC_Order $subscription ): void {
109+
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified by Subscriptions handler.
110+
$request_data = $this->build_request_data( $_POST );
111+
112+
$decision = $this->session_verifier->verify_session(
113+
$this->get_blackbox_session_id(),
114+
self::SOURCE,
115+
$subscription->get_id(),
116+
$request_data
117+
);
118+
119+
if ( ApiClient::DECISION_BLOCK !== $decision ) {
120+
return;
121+
}
122+
123+
FraudProtectionController::log(
124+
'warning',
125+
'Change payment method request blocked.',
126+
array(
127+
'source' => self::SOURCE,
128+
'subscription_id' => $subscription->get_id(),
129+
)
130+
);
131+
132+
$message = $this->blocked_session_notice->get_message_html( 'generic' );
133+
if ( ! wc_has_notice( $message, 'error' ) ) {
134+
wc_add_notice( $message, 'error' );
135+
}
136+
137+
// Stop the request entirely — the Subscriptions handler would otherwise
138+
// proceed to update_payment_method() and process_payment().
139+
// Redirect to the view-subscription page rather than back to the
140+
// change-payment form, which would be unusable in a blocked state.
141+
wp_safe_redirect( $subscription->get_view_order_url() );
142+
exit;
143+
}
144+
}

0 commit comments

Comments
 (0)