WP Manifestindependent plugin directory
manifest / security / checkoutwc-rate-limiter

Checkout Rate Limiter (CheckoutWC)

A wordpress plugin that implements rate limiting for the CheckoutWC plugin

by - · github.com/exgbrian/checkoutwc-rate-limiter

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/exgbrian/checkoutwc-rate-limiter/archive/refs/heads/main.zip

Checkout Rate Limiter for CheckoutWC

A WordPress plugin that blocks card-testing ("carding") and brute-force bots at the CheckoutWC Place Order submit handler.

Why not just limit attempts?

Card-testing bots don't stand out by request volume — they stand out by generating a high ratio of declined payments. Rate limiting raw submissions either throttles real customers or does nothing useful, so this plugin layers five signals:

Layer What it catches Default
Attempt limit (per IP) Raw floods 15 / hour
Decline limit (per IP) The real carding signal 3 / 15 min
Decline limit (per billing email) IP rotation 4 / hour
Site-wide circuit breaker Distributed botnets under every per-IP limit 25 declines / 5 min
Escalating blocks Repeat offenders 15m → 1h → 6h → 24h

A successful payment clears that customer's failure history, so a shopper who fumbles a card number isn't punished afterwards. Customers with a prior paid order get relaxed limits.

It fails open: if the cache or DB misbehaves, checkout still works. A rate limiter that fails closed takes your store offline, which is worse than the attack.

Where it hooks

CheckoutWC's submit button posts to CompleteOrderAction, which fires cfw_before_process_checkout immediately before WC()->checkout()->process_checkout().

  • cfw_before_process_checkout — cheapest possible rejection: no order object, no gateway call. (This hook is not inside a try/catch, so the plugin emits WooCommerce's own AJAX failure JSON shape rather than throwing.)
  • woocommerce_checkout_process — the guaranteed gate. An error notice here halts WooCommerce before create_order(). Covers classic checkout and anything else routing through process_checkout().
  • woocommerce_before_pay_action — the order-pay endpoint, a common carding vector.
  • woocommerce_store_api_checkout_update_order_from_request — WooCommerce Blocks / Store API.

Declines are counted on woocommerce_order_status_failed. The plugin stamps its own resolved IP onto the order so a decline arriving later by webhook is attributed to the shopper rather than to the gateway's server.

Install

Drop cfw-checkout-rate-limiter.php into wp-content/plugins/ and activate.

Because this code can block checkout, a one-click Deactivate is worth having if it ever misfires — and a plugin survives theme switches and theme updates.

It also works pasted into a child theme's functions.php (omit the reopening <?php tag); it detects that context and boots immediately instead of waiting on plugins_loaded, which has already fired by the time themes load. Note that a theme switch then silently removes your protection.

For something that can't be disabled from wp-admin at all, use wp-content/mu-plugins/ — at the cost of losing the kill switch.

Behind Cloudflare or a proxy

Supported and verified. Cloudflare's CF-Connecting-IP header is trusted only when the connecting address is inside Cloudflare's published ranges — otherwise an attacker who finds your origin IP can send a fresh value per request and bypass every limit. Hosts that already rewrite REMOTE_ADDR (mod_remoteip, most managed WP hosts) work with no config.

X-Forwarded-For is deliberately not trusted by default, for the same reason.

If the real visitor IP never reaches PHP, every shopper would collapse into one bucket and start blocking each other — so the plugin detects that it resolved to a proxy edge address, disables itself, fails open, and logs an error rather than mass-blocking real customers.

Verify your setup in WooCommerce → Status (a Checkout Rate Limiter panel), or:

wp eval 'print_r( cfw_rate_limiter_diagnose() );'

status should start with OK, and resolved_client_ip should be a real visitor address — not a 162.158.x / 104.16.x Cloudflare edge IP. If it reports BROKEN, set the header explicitly in wp-config.php:

define( 'CFW_RL_IP_HEADER', 'HTTP_CF_CONNECTING_IP' );

IPv6 is grouped by /64, since a single attacker is routinely handed an entire /64.

Configuration

Every limit is filterable. Nothing needs configuring to start.

add_filter( 'cfw_rate_limiter_config', function ( $config ) {
    $config['fail_limit']   = 2;      // tighter: 2 declines per window
    $config['fail_window']  = 600;    // 10 minutes
    $config['allowlist'][]  = '203.0.113.0/24';  // never limit the office
    $config['breaker_enabled'] = false;          // see caveat below
    return $config;
} );

Other hooks:

// Bypass entirely for a request.
add_filter( 'cfw_rate_limiter_exempt', fn( $exempt, $ip ) => $exempt, 10, 2 );

// Customise the shopper-facing message.
add_filter( 'cfw_rate_limiter_message', fn( $msg, $block ) => $msg, 10, 2 );

// React to a block — push the IP to the Cloudflare WAF, alert Slack, etc.
add_action( 'cfw_rate_limiter_blocked', function ( $ip, $block, $context ) { /* ... */ }, 10, 3 );

// Refresh Cloudflare's edge ranges if they change.
add_filter( 'cfw_rate_limiter_cloudflare_ranges', fn( $ranges ) => $ranges );

Lift a block on a real customer who got caught:

wp eval 'cfw_rate_limiter_unblock("203.0.113.45");'

Caveats

  • The circuit breaker also trips during a genuine gateway outage — mass declines look the same either way. For that reason it only tightens limits and never hard-blocks everyone. Set breaker_enabled => false if you'd rather not have it.
  • Without a persistent object cache (Redis/Memcached) it falls back to transients, whose read-then-write isn't atomic. Under a heavy concurrent flood counts can run slightly low, so it blocks a beat later than it should. With an object cache it uses atomic wp_cache_incr.
  • Cloudflare occasionally adds IP ranges. The safety guard above is the net; use the cfw_rate_limiter_cloudflare_ranges filter to update them.

Also worth doing

  • CheckoutWC already ships Cloudflare Turnstile (CheckoutWC settings → Turnstile). Against carding bots a CAPTCHA is a stronger first line, because it stops attempts before they cost you anything. This plugin is the second layer that catches what gets through.
  • Lock your origin down to Cloudflare's IPs at the firewall. If bots can reach your server directly, they skip Cloudflare, Turnstile, and this limiter all at once.

Requirements

WordPress + WooCommerce, PHP 7.4+ (tested clean on 7.4 and 8.2). CheckoutWC optional — the WooCommerce hooks work without it.

License

GPL-2.0-or-later