Skip to content

Commit ce5a350

Browse files
authored
Merge pull request #28 from WebDecoy/feat/rate-limit-parity
feat: rate limiting parity — sliding window, keys, THROTTLE (#10)
2 parents 16bacc3 + 7b7037f commit ce5a350

5 files changed

Lines changed: 286 additions & 35 deletions

File tree

admin/partials/settings-page.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,32 @@
121121
min="1" max="3600" class="small-text" />
122122
<?php esc_html_e('seconds', 'webdecoy'); ?>
123123
</label>
124+
<br><br>
125+
<label>
126+
<?php esc_html_e('Algorithm', 'webdecoy'); ?>
127+
<select name="webdecoy_options[rate_limit_algorithm]">
128+
<option value="fixed" <?php selected($options['rate_limit_algorithm'] ?? 'fixed', 'fixed'); ?>><?php esc_html_e('Fixed window', 'webdecoy'); ?></option>
129+
<option value="sliding" <?php selected($options['rate_limit_algorithm'] ?? 'fixed', 'sliding'); ?>><?php esc_html_e('Sliding window', 'webdecoy'); ?></option>
130+
</select>
131+
</label>
132+
&nbsp;
133+
<label>
134+
<?php esc_html_e('Count by', 'webdecoy'); ?>
135+
<select name="webdecoy_options[rate_limit_key]">
136+
<option value="ip" <?php selected($options['rate_limit_key'] ?? 'ip', 'ip'); ?>><?php esc_html_e('IP address', 'webdecoy'); ?></option>
137+
<option value="ip_route" <?php selected($options['rate_limit_key'] ?? 'ip', 'ip_route'); ?>><?php esc_html_e('IP + route', 'webdecoy'); ?></option>
138+
<option value="user" <?php selected($options['rate_limit_key'] ?? 'ip', 'user'); ?>><?php esc_html_e('Logged-in user', 'webdecoy'); ?></option>
139+
</select>
140+
</label>
141+
<br><br>
142+
<label>
143+
<input type="checkbox" name="webdecoy_options[rate_limit_dry_run]" value="1"
144+
<?php checked(!empty($options['rate_limit_dry_run'])); ?> />
145+
<?php esc_html_e('Dry run (record without throttling)', 'webdecoy'); ?>
146+
</label>
147+
<p class="description">
148+
<?php esc_html_e('Over-limit requests get a 429 with Retry-After and X-RateLimit-* headers. Sliding window uses a persistent object cache (Redis/Memcached) when available, otherwise falls back to the fixed-window database counter.', 'webdecoy'); ?>
149+
</p>
124150
</td>
125151
</tr>
126152
</table>

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
= 2.2.0 - Unreleased =
44
* Added: Filter rules — write expression-based rules (e.g. `ip.tor or ip.abuse_score > 50`, `ip.country in ["CN","RU"] and req.path matches "^/wp-login"`) evaluated before scoring; block or throttle, with dry-run. New Settings → Rules tab with a rule builder and parse-on-save validation. Expression language is byte-for-byte compatible with @webdecoy/node.
55
* Added: IP enrichment — VPN/proxy/Tor, geo, ASN, and abuse-score data (WebDecoy Cloud) powering the ip.* filter-rule fields, cached 1 hour, fetched only when a rule needs it, fail-open.
6+
* Improved: Rate limiting now runs as a rule in the engine — over-limit requests get a proper 429 with Retry-After and X-RateLimit-* headers (previously it only nudged the bot score). Adds a sliding-window algorithm (exact via a persistent object cache, falling back to the fixed-window database counter), per-IP / per-IP+route / per-user keying, and dry-run.
67
* Added: Stealth-browser detection (F1) — catches scrapers that patch native browser functions to hide automation (puppeteer-extra-stealth, botasaurus, etc.), the class of tool that defeats conventional fingerprinting. Strong tells (patched Function.prototype.toString, a modified navigator.webdriver getter) are decisive; weak tells that real privacy extensions can trigger are scored gently so they never block a legitimate visitor.
78
* Added: Clearance client — bundled @webdecoy/client browser script that silently mints a wd_clearance cookie for real visitors (idle-deferred, once per session, no proof-of-work). Enables tripwire/decoy hits to durably lock out the offending device. Configured via a new publishable Site Key in the WebDecoy Cloud tab.
89
* Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use WebDecoy\Rules\RuleContext;
6+
use WebDecoy\Rules\RuleResult;
7+
use WebDecoy\Rules\RuleInterface;
8+
9+
if (!defined('ABSPATH')) {
10+
exit;
11+
}
12+
13+
/**
14+
* Rate-limit rule — a {@see RuleInterface} so rate limiting flows through the
15+
* same engine as tripwires and filters, and produces a proper THROTTLE (429 +
16+
* Retry-After + X-RateLimit-* headers) instead of merely nudging the bot score.
17+
*
18+
* Faithful to @webdecoy/node's RateLimitRule (increment-then-check, THROTTLE
19+
* default, retryAfter metadata), adapted to PHP's stateless model:
20+
*
21+
* - fixed window → DB (webdecoy_rate_limits), survives across processes.
22+
* - sliding window → the classic two-bucket weighted approximation in a
23+
* persistent object cache (Redis/Memcached). Most serious WP hosts run one,
24+
* giving shared cross-process state node's in-memory limiter can't keep
25+
* across restarts. When no persistent object cache is present, sliding
26+
* transparently falls back to the fixed-window DB path.
27+
*
28+
* keyBy: 'ip' (default), 'ip_route' (IP + path), or 'user' (logged-in user id,
29+
* falling back to IP for anonymous requests).
30+
*/
31+
class WebDecoy_Rate_Limit_Rule implements RuleInterface
32+
{
33+
/** Object-cache group for sliding-window buckets. */
34+
private const CACHE_GROUP = 'webdecoy_rl';
35+
36+
/** @var int */
37+
private $limit;
38+
39+
/** @var int */
40+
private $window;
41+
42+
/** @var string 'fixed' | 'sliding' */
43+
private $algorithm;
44+
45+
/** @var string 'ip' | 'ip_route' | 'user' */
46+
private $keyBy;
47+
48+
/** @var string DENY | THROTTLE */
49+
private $action;
50+
51+
/** @var bool */
52+
private $dryRun;
53+
54+
/**
55+
* @param array<string,mixed> $config
56+
*/
57+
public function __construct(array $config)
58+
{
59+
$this->limit = max(1, (int) ($config['limit'] ?? 60));
60+
$this->window = max(1, (int) ($config['window'] ?? 60));
61+
62+
$algorithm = $config['algorithm'] ?? 'fixed';
63+
$this->algorithm = in_array($algorithm, ['fixed', 'sliding'], true) ? $algorithm : 'fixed';
64+
65+
$keyBy = $config['keyBy'] ?? 'ip';
66+
$this->keyBy = in_array($keyBy, ['ip', 'ip_route', 'user'], true) ? $keyBy : 'ip';
67+
68+
$this->action = ($config['action'] ?? RuleResult::THROTTLE) === RuleResult::DENY ? RuleResult::DENY : RuleResult::THROTTLE;
69+
$this->dryRun = !empty($config['dryRun']);
70+
}
71+
72+
public function getName(): string
73+
{
74+
return 'rate-limit:' . $this->limit . '/' . $this->window . 's';
75+
}
76+
77+
public function evaluate(RuleContext $context): RuleResult
78+
{
79+
$key = $this->resolveKey($context);
80+
81+
$useSliding = $this->algorithm === 'sliding'
82+
&& function_exists('wp_using_ext_object_cache')
83+
&& wp_using_ext_object_cache();
84+
85+
$res = $useSliding ? $this->checkSliding($key) : $this->checkFixed($key);
86+
87+
$remaining = max(0, $this->limit - (int) $res['current']);
88+
$resetIn = max(1, (int) $res['resetAt'] - time());
89+
90+
if (!$res['allowed']) {
91+
return new RuleResult(
92+
$this->dryRun ? RuleResult::ALLOW : $this->action,
93+
$this->getName(),
94+
'Rate limit exceeded: ' . $res['current'] . '/' . $this->limit . ' requests in ' . $this->window . 's window',
95+
[
96+
'current' => (int) $res['current'],
97+
'max' => $this->limit,
98+
'window' => $this->window,
99+
'remaining' => 0,
100+
'retryAfter' => $resetIn,
101+
'resetAt' => (int) $res['resetAt'],
102+
'dryRun' => $this->dryRun,
103+
]
104+
);
105+
}
106+
107+
// Allowed: still carry the counters so the caller can emit X-RateLimit-*.
108+
return new RuleResult(RuleResult::ALLOW, $this->getName(), null, [
109+
'current' => (int) $res['current'],
110+
'max' => $this->limit,
111+
'remaining' => $remaining,
112+
'resetAt' => (int) $res['resetAt'],
113+
]);
114+
}
115+
116+
/**
117+
* Build the counting key. Composite keys are hashed to a fixed length so
118+
* they fit the DB column; a plain IP is kept readable for stats/debugging.
119+
*/
120+
private function resolveKey(RuleContext $context): string
121+
{
122+
if ($this->keyBy === 'user') {
123+
$uid = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
124+
if ($uid > 0) {
125+
return 'u' . $uid;
126+
}
127+
return $context->ip; // anonymous → fall back to IP
128+
}
129+
130+
if ($this->keyBy === 'ip_route') {
131+
$path = explode('?', $context->path, 2)[0];
132+
return substr(sha1($context->ip . '|' . $path), 0, 40);
133+
}
134+
135+
return $context->ip;
136+
}
137+
138+
/**
139+
* Fixed-window check-and-increment via the DB. Increment first, then compare
140+
* (node parity: count > max after increment → denied).
141+
*
142+
* @return array{allowed:bool,current:int,resetAt:int}
143+
*/
144+
private function checkFixed(string $key): array
145+
{
146+
$limiter = new WebDecoy_Rate_Limiter($this->limit, $this->window);
147+
return $limiter->check_and_increment($key);
148+
}
149+
150+
/**
151+
* Sliding-window via a two-bucket weighted approximation in the object cache.
152+
* estimate = prev_bucket_count * overlap_fraction + current_bucket_count.
153+
*
154+
* @return array{allowed:bool,current:int,resetAt:int}
155+
*/
156+
private function checkSliding(string $key): array
157+
{
158+
$now = time();
159+
$win = $this->window;
160+
$idx = intdiv($now, $win);
161+
$currKey = $key . ':' . $idx;
162+
$prevKey = $key . ':' . ($idx - 1);
163+
164+
$curr = wp_cache_incr($currKey, 1, self::CACHE_GROUP);
165+
if ($curr === false) {
166+
wp_cache_add($currKey, 1, self::CACHE_GROUP, $win * 2);
167+
$curr = 1;
168+
}
169+
170+
$prev = (int) wp_cache_get($prevKey, self::CACHE_GROUP);
171+
$elapsed = $now % $win;
172+
$weight = ($win - $elapsed) / $win;
173+
$estimate = ($prev * $weight) + $curr;
174+
175+
return [
176+
'allowed' => $estimate <= $this->limit,
177+
'current' => (int) ceil($estimate),
178+
'resetAt' => ($idx + 1) * $win,
179+
];
180+
}
181+
}

includes/class-webdecoy-rate-limiter.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,50 @@ public function is_exceeded(string $ip): bool
7272
return $count >= $this->limit;
7373
}
7474

75+
/**
76+
* Fixed-window check-and-increment for an arbitrary key. Increments first,
77+
* then reports whether the new count is within the limit (matches
78+
* @webdecoy/node's RateLimitRule semantics). Used by the rate-limit rule.
79+
*
80+
* @param string $key IP or composite/hashed key
81+
* @return array{allowed:bool,current:int,resetAt:int}
82+
*/
83+
public function check_and_increment(string $key): array
84+
{
85+
global $wpdb;
86+
87+
$table = $wpdb->prefix . 'webdecoy_rate_limits';
88+
$now = current_time('mysql');
89+
$window_start_threshold = date('Y-m-d H:i:s', strtotime("-{$this->window} seconds"));
90+
91+
$existing = $wpdb->get_row($wpdb->prepare(
92+
"SELECT request_count, window_start FROM {$table} WHERE ip_address = %s AND window_start > %s",
93+
$key,
94+
$window_start_threshold
95+
), ARRAY_A);
96+
97+
if ($existing) {
98+
$current = (int) $existing['request_count'] + 1;
99+
$wpdb->update($table, ['request_count' => $current], ['ip_address' => $key]);
100+
$reset_at = strtotime($existing['window_start']) + $this->window;
101+
} else {
102+
$wpdb->delete($table, ['ip_address' => $key]);
103+
$wpdb->insert($table, [
104+
'ip_address' => $key,
105+
'request_count' => 1,
106+
'window_start' => $now,
107+
]);
108+
$current = 1;
109+
$reset_at = strtotime($now) + $this->window;
110+
}
111+
112+
return [
113+
'allowed' => $current <= $this->limit,
114+
'current' => $current,
115+
'resetAt' => $reset_at,
116+
];
117+
}
118+
75119
/**
76120
* Increment request count for an IP
77121
*

webdecoy.php

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,12 @@ private function load_options(): void
206206
'rate_limit_enabled' => true,
207207
'rate_limit_requests' => 60,
208208
'rate_limit_window' => 60,
209+
// Algorithm: fixed window (DB) or sliding window (object cache when a
210+
// persistent one is present, else fixed). Key: per IP, IP+route, or
211+
// logged-in user. Dry-run records without throttling.
212+
'rate_limit_algorithm' => 'fixed',
213+
'rate_limit_key' => 'ip',
214+
'rate_limit_dry_run' => false,
209215

210216
// Tripwires (F4 deception layer). Deterministic, zero-false-positive:
211217
// a request for a scanner-bait honeypot path is automated by
@@ -751,6 +757,7 @@ public function load_includes(): void
751757
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php';
752758
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php';
753759
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php';
760+
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php';
754761

755762
if (class_exists('WooCommerce')) {
756763
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php';
@@ -837,15 +844,9 @@ public function early_check(): void
837844
}
838845
}
839846

840-
// Check rate limit
841-
if ($this->options['rate_limit_enabled']) {
842-
$rateLimiter = new WebDecoy_Rate_Limiter();
843-
if ($rateLimiter->is_exceeded($ip)) {
844-
$this->handle_rate_limit_exceeded($ip);
845-
return;
846-
}
847-
$rateLimiter->increment($ip);
848-
}
847+
// Rate limiting now runs as a rule inside the engine above (THROTTLE →
848+
// 429 + Retry-After + X-RateLimit-* headers), so it evaluates in order
849+
// with tripwires and filters rather than as a separate pre-check.
849850

850851
// Run bot detection with request path for MITRE ATT&CK path analysis
851852
$detector = $this->get_detector();
@@ -949,6 +950,18 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine
949950
}
950951
}
951952

953+
// Rate limiting runs last: a deterministic tripwire/filter DENY should
954+
// win over a THROTTLE for the same request.
955+
if (!empty($this->options['rate_limit_enabled'])) {
956+
$rules[] = new WebDecoy_Rate_Limit_Rule([
957+
'limit' => (int) ($this->options['rate_limit_requests'] ?? 60),
958+
'window' => (int) ($this->options['rate_limit_window'] ?? 60),
959+
'algorithm' => $this->options['rate_limit_algorithm'] ?? 'fixed',
960+
'keyBy' => $this->options['rate_limit_key'] ?? 'ip',
961+
'dryRun' => !empty($this->options['rate_limit_dry_run']),
962+
]);
963+
}
964+
952965
if ($rules === []) {
953966
return null;
954967
}
@@ -1062,13 +1075,18 @@ private function collect_request_headers(): array
10621075
private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, string $ip): void
10631076
{
10641077
if ($result->action === \WebDecoy\Rules\RuleResult::THROTTLE) {
1065-
$retryAfter = 60;
1066-
if (is_array($result->metadata) && isset($result->metadata['retryAfter'])) {
1067-
$retryAfter = max(1, intval($result->metadata['retryAfter']));
1068-
}
1078+
$meta = is_array($result->metadata) ? $result->metadata : [];
1079+
$retryAfter = isset($meta['retryAfter']) ? max(1, intval($meta['retryAfter'])) : 60;
10691080
nocache_headers();
10701081
status_header(429);
10711082
header('Retry-After: ' . $retryAfter);
1083+
if (isset($meta['max'])) {
1084+
header('X-RateLimit-Limit: ' . (int) $meta['max']);
1085+
header('X-RateLimit-Remaining: ' . (int) ($meta['remaining'] ?? 0));
1086+
if (isset($meta['resetAt'])) {
1087+
header('X-RateLimit-Reset: ' . (int) $meta['resetAt']);
1088+
}
1089+
}
10721090
wp_die(
10731091
esc_html__('Too many requests. Please try again later.', 'webdecoy'),
10741092
esc_html__('Too Many Requests', 'webdecoy'),
@@ -1267,28 +1285,6 @@ private function serve_challenge_page(string $ip): void
12671285
exit;
12681286
}
12691287

1270-
/**
1271-
* Handle rate limit exceeded
1272-
*
1273-
* @param string $ip
1274-
*/
1275-
private function handle_rate_limit_exceeded(string $ip): void
1276-
{
1277-
// Add rate exceeded flag to detection
1278-
$detector = $this->get_detector();
1279-
$result = $detector->analyze(['rate_exceeded' => true]);
1280-
1281-
// Log rate limit exceeded
1282-
$this->log_detection($result, $ip);
1283-
1284-
if ($result->getScore() >= $this->options['min_score_to_block']) {
1285-
$this->handle_blocking($result, $ip);
1286-
} else {
1287-
// Just block temporarily for rate limiting
1288-
$this->block_request(__('Too many requests. Please try again later.', 'webdecoy'));
1289-
}
1290-
}
1291-
12921288
/**
12931289
* Block a request
12941290
*
@@ -1719,6 +1715,9 @@ public function sanitize_options(array $input): array
17191715
$sanitized['rate_limit_enabled'] = !empty($input['rate_limit_enabled']);
17201716
$sanitized['rate_limit_requests'] = max(1, intval($input['rate_limit_requests'] ?? 60));
17211717
$sanitized['rate_limit_window'] = max(1, intval($input['rate_limit_window'] ?? 60));
1718+
$sanitized['rate_limit_algorithm'] = in_array($input['rate_limit_algorithm'] ?? 'fixed', ['fixed', 'sliding'], true) ? $input['rate_limit_algorithm'] : 'fixed';
1719+
$sanitized['rate_limit_key'] = in_array($input['rate_limit_key'] ?? 'ip', ['ip', 'ip_route', 'user'], true) ? $input['rate_limit_key'] : 'ip';
1720+
$sanitized['rate_limit_dry_run'] = !empty($input['rate_limit_dry_run']);
17221721

17231722
// Tripwires
17241723
$sanitized['tripwire_enabled'] = !empty($input['tripwire_enabled']);

0 commit comments

Comments
 (0)