Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.22% covered (warning)
82.22%
37 / 45
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ajax
82.22% covered (warning)
82.22%
37 / 45
50.00% covered (danger)
50.00%
1 / 2
8.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rate_limit_checkout
81.40% covered (warning)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
7.32
1<?php
2/**
3 * Code to run on WooCommerce AJAX checkout.
4 *
5 * When "Place Order" is clicked, this should record the user's IP address and check they have not placed too many
6 * orders recently.
7 *
8 * @author     BrianHenryIE <BrianHenryIE@gmail.com>
9 * @link       https://BrianHenryIE.com
10 * @since      1.0.0
11 * @package brianhenryie/bh-wc-checkout-rate-limiter
12 */
13
14namespace BrianHenryIE\Checkout_Rate_Limiter\WooCommerce;
15
16use BrianHenryIE\Checkout_Rate_Limiter\RateLimit\Rate;
17use BrianHenryIE\Checkout_Rate_Limiter\Settings_Interface;
18use BrianHenryIE\Checkout_Rate_Limiter\WP_Rate_Limiter\WordPress_Rate_Limiter;
19use Psr\Log\LoggerAwareTrait;
20use Psr\Log\LoggerInterface;
21
22/**
23 * Hooked on wc_ajax_checkout earlier than WooCommerce's own processing code.
24 *
25 * @see WordPress_RateLimiter
26 *
27 * Class Ajax
28 * @package brianhenryie/bh-wc-checkout-rate-limiter
29 */
30class Ajax {
31
32    use LoggerAwareTrait;
33
34    /**
35     * The plugin's settings.
36     *
37     * @var Settings_Interface
38     */
39    protected Settings_Interface $settings;
40
41    /**
42     * Instantiate.
43     *
44     * @param Settings_Interface $settings The plugin settings.
45     * @param LoggerInterface    $logger PSR logger.
46     */
47    public function __construct( Settings_Interface $settings, LoggerInterface $logger ) {
48        $this->settings = $settings;
49        $this->setLogger( $logger );
50    }
51
52    /**
53     * On rate limit exceeded, return a 429 JSON error to the client.
54     * On success, function returns so the checkout can be processed as normal.
55     *
56     * No `Retry-After` header is added, since this is targeted at the WooCommerce AJAX checkout.
57     *
58     * @hooked wc_ajax_checkout
59     */
60    public function rate_limit_checkout(): void {
61
62        if ( ! $this->settings->is_enabled() ) {
63            $this->logger->debug( 'Not enabled / no limits set' );
64            return;
65        }
66
67        $limits = $this->settings->get_checkout_rate_limits();
68
69        if ( empty( $limits ) ) {
70            $this->logger->debug( 'No limits set' );
71            return;
72        }
73
74        $ip_address = \WC_Geolocation::get_ip_address();
75
76        $block = false;
77
78        foreach ( $limits as $interval => $allowed_access_count ) {
79
80            $this->logger->debug( "Checking {$ip_address} rate limit {$allowed_access_count} per {$interval} seconds." );
81
82            $rate = Rate::custom( $allowed_access_count, $interval );
83
84            $rate_limiter = new WordPress_Rate_Limiter( $rate, 'checkout' );
85
86            try {
87                $status = $rate_limiter->limitSilently( $ip_address );
88            } catch ( \RuntimeException $e ) {
89                $this->logger->error(
90                    'Rate Limiter encountered an error when storing the access count.',
91                    array(
92                        'exception' => $e,
93                    )
94                );
95                // The behaviour here on an error is to NOT rate-limit.
96                continue;
97            }
98
99            /**
100             * TODO: Log the $_REQUEST data (but remove credit card details).
101             *
102             * @see WC_Checkout::get_posted_data()
103             */
104            if ( $status->limitExceeded() ) {
105
106                $this->logger->notice(
107                    "{$ip_address} blocked with {$status->getRemainingAttempts()} remaining attempts for rate limit {$allowed_access_count} per {$interval} seconds.",
108                    array(
109                        'interval'             => $interval,
110                        'allowed_access_count' => $allowed_access_count,
111                        'status'               => $status,
112                        'ip_address'           => $ip_address,
113                    )
114                );
115
116                $block = true;
117            } else {
118
119                $this->logger->debug(
120                    "{$ip_address} allowed with {$status->getRemainingAttempts()} remaining attempts for rate limit {$allowed_access_count} per {$interval} seconds.",
121                    array(
122                        'interval'             => $interval,
123                        'allowed_access_count' => $allowed_access_count,
124                        'status'               => $status,
125                    )
126                );
127            }
128        }
129
130        if ( $block ) {
131            // No real point adding headers here.
132            wp_send_json_error( null, 429 );
133        }
134    }
135}