Rate limit "Place Order" on WooCommerce checkout.
Rate limits /?wc-ajax=checkout
by IP.
Download the latest version from releases and configure inside WooCommerce settings:
Then when the "Place Order" button on checkout is pressed too frequently, the customer will be blocked:
Three rates can be set, progressively punishing problematic people.
Your website can be attacked by running 100s of fake credit card numbers through your gateway. Your gateway will often charge per transaction, even if they are declined. You also likely have a daily limit of transactions and of total number of transactions. When this is reached, you can no longer take legitimate orders. Some examples:
21 Sept 2017 on reddit.com/r/Wordpress
So we are having problems of people who purchased some list of like 100 credit card numbers and try to place an order with us. We see in the order notes "Your transaction has been declined" come up in these people like 30 times meaning they tried 30 different credit cards.
So I'm wondering if there is a way to automatically block the customer's IP address from ordering if they received over 3 failed attempts.
26 Feb 2020 on reddit.com/r/woocommerce
Today we had something new happen... our credit card processor temporarily disabled our account because they said someone was using our site to run a carding attack on the site.
Has anyone dealt with this before? Did you go with reCaptcha or another method? Curious about the details.
One thing I don't get is how they're doing this without placing a ton of orders. I'm sure it is some URL or something but curious about how it would work.
14 April 2020 on reddit.com/r/woocommerce
Yesterday, my small shop started receiving hundreds of failed orders from the same "person" (bot). The same name and billing info, and IP address were used for all of these failed orders. Different credit card numbers were used (all Mastercard). The order was for the cheapest item in the shop.
...
I first blacklisted the origin's IP, but the same thing began happening immediately from another IP. I knew blacklisting that wouldn't help, so I switched the shop to only allow orders from users with accounts.
...
I'm contacting my processor today to see how much trouble I'm in.
This solution uses rate limiting per IP address to stop how often one IP can send a request to /?wc-ajax=checkout
The WooCommerce code to handle /?wc-ajax=checkout
can be found at:
add_action( 'template_redirect', array( __CLASS__, 'do_wc_ajax' ), 0 );
do_action( 'wc_ajax_' . $action );
, which in our case $action
is checkout
wc_ajax_checkout
is the WC_AJAX::checkout() function, which is added at default priority 10, making it easy to slip in before. Then, the simplified version of what this plugin does is:
add_action( 'wc_ajax_checkout', 'rate_limit_checkout', 0 );
function rate_limit_checkout() {
$rate_limiter = new WordPress_RateLimiter();
$ip_address = WC_Geolocation::get_ip_address();
try {
$rate_limiter->limit( $ip_address, Rate::perMinute( 5 ) );
} catch ( LimitExceeded $exception ) {
wp_send_json_error( null, 429 );
}
}
Rather than write my own rate limiting code, I used nikolaposa/rate-limit, then added a PSR-16 wrapper for it (PSR-16), and used a PSR-16 implementation of WordPress transients as the cache. All that code should be external to this plugin.
The niceties of the plugin are:
The correct way to address this problem for most people is with a captcha. We were not using captcha because when we enabled a captcha, customers could not checkout. Ultimately, this was a problem with WooCommerce Anti-Fraud, a plugin with a litany of issues.
Additionally, if you use Cloudflare, the logical thing seems to be to use Cloudflare's rate limiting, but:
Cloudflare's rate limiting could still be used on /checkout/
. In the case I encountered, this would have helped because the bot was reloading /checkout/
each time, but I think a better designed bot could submit the AJAX checkout repeatedly without reloading the whole page.
For whole-site rate-limiting, I wish there were a tool to take recent Apache access logs and determine a 75% percentile customer access/minute, then rate limit everyone else. (where '75' can be learned).
Although this plugin is using transients, WordPress's implementation of transients (option.php:791's get_transient()) defers their storage to any available object cache. Using the object cache directly presumably affords benefits.
function get_transient( $transient ) {
...
if ( wp_using_ext_object_cache() ) {
$value = wp_cache_get( $transient, 'transient' );
WordFence has rate limiting but it did not offer the option to be specific to /?wc-ajax=checkout
. When I looked at its general rate limiting, and at Cloudflare's rate limiting, it is very hard to determine the correct numbers to use.
When a limit is reached, an additional punishment seems reasonable. This could be achieved easily with another transient.
The author of one of the Reddit posts quoted above replied to my query (after I had written most of this!), and the interesting difference of approach was that he emptied the "customer"'s cart every time they exceeded the rate limit. Clever! The only reason I haven't done it here is because the time taken so far. I like it, but I'm not sure I need it.
Having already written the crux of this, while I was searching my project, I found the class WC_Rate_Limiter. It doesn't seem to be an appropriate replacement, but it's always enlightening to see another corner of the WooCommerce code I haven't encountered.
A rate limiter for the WP REST API: WP REST Cop. I looked at this initially and hoped I could fork it or use it as a library, but as I read more I opted, after some hops, for the other approach. I'm a fan of the author, whose SatisPress plugin is essential.
Judging from the logs I've seen, the attack I've seen was probably via Selenium.
I saw that based on the User-Agent and the cadence of requests. Interesting articles:
In an unrelated project I needed to block ranges of IPs which I was able to do with WooCommerce Conditional Shipping and Payments by writing a plugin: IP Address Condition for WooCommerce Conditional Shipping and Payments.
Clone this repo, open PhpStorm, then run composer install
to install the dependencies.
git clone https://github.com/brianhenryie/bh-wc-checkout-rate-limiter.git;
open -a PhpStorm ./;
composer install;
For integration and acceptance tests, a local webserver must be running with localhost:8080/bh-wc-checkout-rate-limiter/
pointing at the root of the repo. MySQL must also be running locally – with two databases set up with:
mysql_username="root"
mysql_password="secret"
# export PATH=${PATH}:/usr/local/mysql/bin
# Make .env available
# To bash:
# export $(grep -v '^#' .env.testing | xargs)
# To zsh:
# source .env.testing
# Create the database user:
# MySQL
# mysql -u $mysql_username -p$mysql_password -e "CREATE USER '"$TEST_DB_USER"'@'%' IDENTIFIED WITH mysql_native_password BY '"$TEST_DB_PASSWORD"';";
# or MariaDB
# mysql -u $mysql_username -p$mysql_password -e "CREATE USER '"$TEST_DB_USER"'@'%' IDENTIFIED BY '"$TEST_DB_PASSWORD"';";
# Create the databases:
mysql -u $mysql_username -p$mysql_password -e "CREATE DATABASE "$TEST_SITE_DB_NAME"; USE "$TEST_SITE_DB_NAME"; GRANT ALL PRIVILEGES ON "$TEST_SITE_DB_NAME".* TO '"$TEST_DB_USER"'@'%';";
mysql -u $mysql_username -p$mysql_password -e "CREATE DATABASE "$TEST_DB_NAME"; USE "$TEST_DB_NAME"; GRANT ALL PRIVILEGES ON "$TEST_DB_NAME".* TO '"$TEST_DB_USER"'@'%';";
See documentation on WordPress.org and GitHub.com.
Correct errors where possible and list the remaining with:
vendor/bin/phpcbf; vendor/bin/phpcs
Tests use the Codeception add-on WP-Browser and include vanilla PHPUnit tests with WP_Mock.
Run tests with:
vendor/bin/codecept run unit;
vendor/bin/codecept run wpunit;
vendor/bin/codecept run integration;
vendor/bin/codecept run acceptance;
Show code coverage (unit+wpunit):
XDEBUG_MODE=coverage composer run-script coverage-tests
Static analysis:
vendor/bin/phpstan analyse --memory-limit 1G
To save changes made to the acceptance database:
export $(grep -v '^#' .env.testing | xargs)
mysqldump -u $TEST_SITE_DB_USER -p$TEST_SITE_DB_PASSWORD $TEST_SITE_DB_NAME > tests/_data/dump.sql
To clear Codeception cache after moving/removing test files:
vendor/bin/codecept clean
See github.com/BrianHenryIE/WordPress-Plugin-Boilerplate for initial setup rationale.