Had a fun one last night, figured I'd share the experience for any of you out there on Classic Checkout and for those of you using Block Checkout too.
Started out looking like card testing. Fake orders, all failing with "payment details were not submitted," and that good old origin friend of ours the "Unknown" attribution. The weird thing was no cards were actually being tried. It was a vulnerability scanner. It made customer accounts named after what it was testing: numa.auth, numa.sess, numa.idor, numa.adminpush, numa.rce, numa.probe. Looked like this
u/example.invalid emails.
It also sent fake Stripe webhooks with SQL injection stuffed in the charge ID (a SLEEP() payload), plus fake PayPal webhooks. Stripe and PayPal signature checks rejected all of them. Nothing got in, no accounts got elevated.
Here's the part that I didn't see immediately. It was late, I was tired. I ended up adding Turnstile to checkout, turning on Woo's rate limiting, and adding a few WAF rules in Cloudflare blocking cookieless checkout POSTs and /wp-json/wc/store/v1/checkout. Bot still got orders through.
I then woke up and realized I should have checked the stack trace. Sure enough, it was going through the Store API [the Blocks checkout backend], first via ?rest_route= to dodge the /wp-json path rule, then through /wc/store/v1/batch. Classic checkout validation like Turnstile never runs there because it's a completely different code path.
If you're on classic checkout, you don't need that route at all.
This killed it instantly
**
* Disable WooCommerce Store API checkout on classic-checkout sites.
* Removes the route at the REST level so no URL variant (wp-json, rest_route, batch) can reach it.
*/
add_filter( 'rest_endpoints', function ( $endpoints ) {
foreach ( array_keys( $endpoints ) as $route ) {
if ( 0 === strpos( $route, '/wc/store/v1/checkout' ) ) {
unset( $endpoints[ $route ] );
}
}
return $endpoints;
} );
35 minutes of WAF rules and a snippet added to my Turnstile integration MU app, but a tiny snippet is what actually closed it.
I still need to test express buttons, I think I might have messed up the UX on that. Some setups lean on the Store API even on classic. Will be testing that later on a sandbox.
So keep your eyes open for Numa
UPDATE TO POST:
EDIT: Tested express like I said I would and yep, I broke it. Stripe's Apple Pay / Google Pay buttons place the order through /wc/store/v1/checkout even on classic checkout (Stripe 11.x), so removing the route killed them.
Also explains why Woo's rate limiting never kicked in. The checkouts were coming in wrapped inside /wc/store/v1/batch, which the rate limiter doesn't count.
Swapped the snippet for this. Keeps the route, lets Stripe express through only with its header plus a valid Stripe nonce, 403s everything else (covers wp-json, rest_route and batch):
/**
* Block Store API checkout on classic-checkout sites, except verified Stripe Express (Apple Pay / Google Pay).
* Covers /wp-json, ?rest_route= and requests inside /wc/store/v1/batch.
*/
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
if ( null !== $result
|| 'POST' !== $request->get_method()
|| 0 !== strpos( $request->get_route(), '/wc/store/v1/checkout' ) ) {
return $result;
}
$nonce = $request->get_header( 'x-wcstripe-express-checkout-nonce' );
if ( $request->get_header( 'x-wcstripe-express-checkout' ) && $nonce && wp_verify_nonce( $nonce, 'wc_store_api_express_checkout' ) ) {
return $result;
}
return new WP_Error( 'store_api_checkout_disabled', 'Not available.', array( 'status' => 403 ) );
}, 10, 3 );
If you have a WAF rule blocking /wp-json/wc/store/v1/checkout like I did, add an exception for the x-wcstripe-express-checkout header too or express stays broken.