Skip to content

BrianHenryIE/bh-wc-checkout-rate-limiter

Repository files navigation

WordPress tested 6.0 PHPCS WPCS PHPStan PHPUnit

Checkout Rate Limiter

Rate limit "Place Order" on WooCommerce checkout.

Rate limits /?wc-ajax=checkout by IP.

Install

Download the latest version from releases and configure inside WooCommerce settings:

Settings page

Then when the "Place Order" button on checkout is pressed too frequently, the customer will be blocked:

Checkout page

Three rates can be set, progressively punishing problematic people.

Background

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:

Anyone know how I can limit a brute force order attempt (woocommerce)

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.

How to prevent a carding attack on a woocommerce site?

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.

Hundreds of failed orders, some switched to completed without admin action

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.

Operation

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:

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 );
    }
}

Detail

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:

  • Logs
  • Settings link on plugins.php
  • Settings link as admin notice until configured (or one week)

Notes

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 Rate Limiting Rule

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).

Transients / Cache

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

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.

Cooling off

When a limit is reached, an additional punishment seems reasonable. This could be achieved easily with another transient.

Empty cart

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.

WC_Rate_Limiter

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.

WP REST Cop

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.

Selenium

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:

See Also

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.

Contributing

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"'@'%';";

WordPress Coding Standards

See documentation on WordPress.org and GitHub.com.

Correct errors where possible and list the remaining with:

vendor/bin/phpcbf; vendor/bin/phpcs

Tests

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

More Information

See github.com/BrianHenryIE/WordPress-Plugin-Boilerplate for initial setup rationale.

Acknowledgements

About

Prevent card-attack fraud on WooCommerce stores by rate-limiting the "place order" button

Topics

Resources

License

Stars

Watchers

Forks

Languages