PluginProbe
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment / 3.1.13
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment v3.1.13
3.1.13 3.1.12 3.1.11 3.1.10 3.1.9 3.1.8 3.1.7 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 122 releases
firebox / Inc / Core / Helpers / RateLimit.php

RateLimit.php in FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment 3.1.13, at Inc/Core/Helpers/RateLimit.php

254 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package FireBox
4 * @version 3.1.13 Free
5 *
6 * @author FirePlugins <info@fireplugins.com>
7 * @link https://www.fireplugins.com
8 * @copyright Copyright © 2026 FirePlugins All Rights Reserved
9 * @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
10 */
11
12 namespace FireBox\Core\Helpers;
13
14 if (!defined('ABSPATH'))
15 {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * Per-client throttle for the public endpoints.
21 *
22 * The public endpoints are protected only by a nonce that is printed into every page a
23 * campaign renders on, is identical for all logged-out visitors and — because those pages
24 * are usually cached — has to stay valid for hours. It is a CSRF token, not a rate limit.
25 */
26 class RateLimit
27 {
28 /**
29 * Prefix for the transients backing each bucket.
30 *
31 * @var string
32 */
33 const TRANSIENT_PREFIX = 'fbox_rl_';
34
35 /**
36 * Records a hit and returns whether the caller is still within its allowance.
37 *
38 * @param string $bucket Identifies the action being limited.
39 * @param int $limit Hits permitted per window.
40 * @param int $window Window length in seconds.
41 * @param bool $fail_open What to do when the caller cannot be identified. See getClientIdentifier().
42 *
43 * @return bool True while under the limit, false once it is exceeded.
44 */
45 public static function attempt($bucket = '', $limit = 10, $window = MINUTE_IN_SECONDS, $fail_open = true)
46 {
47 if (self::isLimited($bucket, $limit, $fail_open))
48 {
49 return false;
50 }
51
52 self::hit($bucket, $window, $fail_open);
53
54 return true;
55 }
56
57 /**
58 * Returns whether the caller has exhausted its allowance, without recording a hit.
59 *
60 * @param string $bucket Identifies the action being limited.
61 * @param int $limit Hits permitted per window.
62 * @param bool $fail_open What to do when the caller cannot be identified. See getClientIdentifier().
63 *
64 * @return bool
65 */
66 public static function isLimited($bucket = '', $limit = 10, $fail_open = true)
67 {
68 /**
69 * Allows a site to tune or switch off throttling for an endpoint.
70 *
71 * Returning 0 (or a negative limit) disables the limit for that bucket.
72 *
73 * @param int $limit
74 * @param string $bucket
75 */
76 $limit = (int) apply_filters('firebox/rate_limit/limit', $limit, $bucket);
77
78 if ($limit <= 0)
79 {
80 return false;
81 }
82
83 $key = self::getKey($bucket, $fail_open);
84
85 if ($key === '')
86 {
87 // No way to identify the caller, so no way to limit them fairly.
88 return false;
89 }
90
91 return self::readCount($key) >= $limit;
92 }
93
94 /**
95 * Returns the hits recorded in the window that is currently open.
96 *
97 * A window that has already elapsed counts as zero: the stored value is only cleared
98 * lazily, and a value written by an older version has a shape we no longer read.
99 *
100 * @param string $key
101 *
102 * @return int
103 */
104 private static function readCount($key)
105 {
106 $stored = get_transient($key);
107
108 if (!is_array($stored) || !isset($stored['count'], $stored['reset']))
109 {
110 return 0;
111 }
112
113 return (int) $stored['reset'] > time() ? (int) $stored['count'] : 0;
114 }
115
116 /**
117 * Records a hit against the caller's bucket.
118 *
119 * The window is fixed: it opens on the first hit and the count resets once it
120 * elapses. The expiry is deliberately not pushed forward on later hits — doing so
121 * would mean the count never resets for a bucket that sees steady traffic, and a
122 * shared address (an office, a mobile carrier, a proxy) would creep to the limit
123 * over many minutes while staying well under the intended rate.
124 *
125 * Note the get/set pair is not atomic, so concurrent requests can slightly
126 * exceed the limit. The limiter fails open by design; it is a brake, not a lock.
127 *
128 * @param string $bucket Identifies the action being limited.
129 * @param int $window Window length in seconds.
130 * @param bool $fail_open What to do when the caller cannot be identified. See getClientIdentifier().
131 *
132 * @return void
133 */
134 public static function hit($bucket = '', $window = MINUTE_IN_SECONDS, $fail_open = true)
135 {
136 $window = (int) apply_filters('firebox/rate_limit/window', $window, $bucket);
137 $window = $window > 0 ? $window : MINUTE_IN_SECONDS;
138
139 $key = self::getKey($bucket, $fail_open);
140
141 if ($key === '')
142 {
143 return;
144 }
145
146 $now = time();
147 $stored = get_transient($key);
148
149 if (!is_array($stored) || !isset($stored['count'], $stored['reset']) || (int) $stored['reset'] <= $now)
150 {
151 // No window open (or the last one elapsed), so this hit opens a new one.
152 $stored = ['count' => 0, 'reset' => $now + $window];
153 }
154
155 $stored['count'] = (int) $stored['count'] + 1;
156
157 set_transient($key, $stored, max(1, (int) $stored['reset'] - $now));
158 }
159
160 /**
161 * Returns the transient key for a bucket and the current client.
162 *
163 * @param string $bucket
164 * @param bool $fail_open
165 *
166 * @return string
167 */
168 private static function getKey($bucket = '', $fail_open = true)
169 {
170 $client = self::getClientIdentifier($fail_open);
171
172 if ($client === '')
173 {
174 return '';
175 }
176
177 return self::TRANSIENT_PREFIX . md5($bucket . '|' . $client);
178 }
179
180 /**
181 * Identifies the current client.
182 *
183 * Logged-in users are identified by ID. Everyone else is identified by REMOTE_ADDR.
184 *
185 * Forwarded headers (X-Forwarded-For, CF-Connecting-IP and friends) are deliberately
186 * not consulted. They are attacker-controlled wherever the origin can be reached
187 * directly, and trusting them does not merely let an attacker slip their own limit:
188 * it lets them choose *whose* bucket to fill. Sending a burst under a victim's
189 * address would lock that visitor out of a form they are entitled to use. A site
190 * behind a proxy it trusts can supply the real address through the filter below.
191 *
192 * A private or loopback REMOTE_ADDR means a proxy did not pass the visitor's address
193 * on, so every visitor arrives looking like the same client. That leaves no honest
194 * way to tell them apart, and $fail_open decides what to do about it:
195 *
196 * - true — do not limit at all. Right for the public brakes, where sharing one
197 * bucket would cap the whole site instead of one visitor.
198 * - false — put everyone in one bucket. Right where the limiter guards a secret
199 * rather than a resource, and letting every caller through unthrottled
200 * would remove the protection entirely.
201 *
202 * @param bool $fail_open
203 *
204 * @return string
205 */
206 private static function getClientIdentifier($fail_open = true)
207 {
208 if ($user_id = get_current_user_id())
209 {
210 return 'user:' . $user_id;
211 }
212
213 $ip = isset($_SERVER['REMOTE_ADDR'])
214 ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))
215 : '';
216
217 if ($ip !== '' && !self::isRoutable($ip))
218 {
219 $ip = '';
220 }
221
222 /**
223 * Lets a site behind a trusted reverse proxy supply the real client IP.
224 *
225 * Hook this only where the proxy is guaranteed to overwrite the header it reads
226 * — otherwise any client can forge the value, both to slip its own limit and to
227 * fill another visitor's. Returning an empty string leaves the caller
228 * unidentified, which $fail_open then resolves.
229 *
230 * @param string $ip
231 */
232 $ip = (string) apply_filters('firebox/rate_limit/client_ip', $ip);
233
234 if ($ip !== '')
235 {
236 return 'ip:' . $ip;
237 }
238
239 return $fail_open ? '' : 'shared';
240 }
241
242 /**
243 * Returns whether an address could belong to a visitor out on the internet.
244 *
245 * @param string $ip
246 *
247 * @return bool
248 */
249 private static function isRoutable($ip)
250 {
251 return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
252 }
253 }
254