From 7b7037f68a9168aa50f4c51cb6ee117519b6d696 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 16:31:04 -0500 Subject: [PATCH] feat(rate-limit): sliding window, custom keys, THROTTLE semantics (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors rate limiting into a rule on the engine, closing the parity gap with @webdecoy/node's RateLimitRule. - WebDecoy_Rate_Limit_Rule implements the SDK RuleInterface, so it evaluates in order with tripwires/filters (added last, so a deterministic DENY wins over a THROTTLE). Over-limit now returns a proper 429 + Retry-After + X-RateLimit-Limit/Remaining/Reset headers — previously an exceeded limit only added +25 to the bot score. - Sliding window: the classic two-bucket weighted approximation in a persistent object cache (Redis/Memcached) when present, transparently falling back to the fixed-window DB counter otherwise. - keyBy: IP (default), IP+route, or logged-in user id (WP-specific; anon falls back to IP). Composite keys are hashed to fit the DB column. - Per-rule limit/window/algorithm/key/dry-run in Protection settings; the existing global limit becomes the default rule, so effective limits are unchanged for existing installs. Increment-then-check matches node. - Removed the standalone pre-engine rate-limit block and the now-dead handle_rate_limit_exceeded(). - Fixed a latent constructor bug (absent config key re-read the undefined key instead of the default). Verified fixed + sliding admit/deny, per-IP/per-user independence, dry-run, header metadata, and sliding->fixed fallback with a stubbed harness. Closes #10. Part of #16. Co-authored-by: Claude --- admin/partials/settings-page.php | 26 +++ changelog.txt | 1 + includes/class-webdecoy-rate-limit-rule.php | 181 ++++++++++++++++++++ includes/class-webdecoy-rate-limiter.php | 44 +++++ webdecoy.php | 69 ++++---- 5 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 includes/class-webdecoy-rate-limit-rule.php diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index ed46b62..d53f6d7 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -121,6 +121,32 @@ min="1" max="3600" class="small-text" /> +

+ +   + +

+ +

+ +

diff --git a/changelog.txt b/changelog.txt index 4e6df12..44f1925 100644 --- a/changelog.txt +++ b/changelog.txt @@ -3,6 +3,7 @@ = 2.2.0 - Unreleased = * 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. * 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. +* 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. * 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. * 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. * Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node. diff --git a/includes/class-webdecoy-rate-limit-rule.php b/includes/class-webdecoy-rate-limit-rule.php new file mode 100644 index 0000000..dd80261 --- /dev/null +++ b/includes/class-webdecoy-rate-limit-rule.php @@ -0,0 +1,181 @@ + $config + */ + public function __construct(array $config) + { + $this->limit = max(1, (int) ($config['limit'] ?? 60)); + $this->window = max(1, (int) ($config['window'] ?? 60)); + + $algorithm = $config['algorithm'] ?? 'fixed'; + $this->algorithm = in_array($algorithm, ['fixed', 'sliding'], true) ? $algorithm : 'fixed'; + + $keyBy = $config['keyBy'] ?? 'ip'; + $this->keyBy = in_array($keyBy, ['ip', 'ip_route', 'user'], true) ? $keyBy : 'ip'; + + $this->action = ($config['action'] ?? RuleResult::THROTTLE) === RuleResult::DENY ? RuleResult::DENY : RuleResult::THROTTLE; + $this->dryRun = !empty($config['dryRun']); + } + + public function getName(): string + { + return 'rate-limit:' . $this->limit . '/' . $this->window . 's'; + } + + public function evaluate(RuleContext $context): RuleResult + { + $key = $this->resolveKey($context); + + $useSliding = $this->algorithm === 'sliding' + && function_exists('wp_using_ext_object_cache') + && wp_using_ext_object_cache(); + + $res = $useSliding ? $this->checkSliding($key) : $this->checkFixed($key); + + $remaining = max(0, $this->limit - (int) $res['current']); + $resetIn = max(1, (int) $res['resetAt'] - time()); + + if (!$res['allowed']) { + return new RuleResult( + $this->dryRun ? RuleResult::ALLOW : $this->action, + $this->getName(), + 'Rate limit exceeded: ' . $res['current'] . '/' . $this->limit . ' requests in ' . $this->window . 's window', + [ + 'current' => (int) $res['current'], + 'max' => $this->limit, + 'window' => $this->window, + 'remaining' => 0, + 'retryAfter' => $resetIn, + 'resetAt' => (int) $res['resetAt'], + 'dryRun' => $this->dryRun, + ] + ); + } + + // Allowed: still carry the counters so the caller can emit X-RateLimit-*. + return new RuleResult(RuleResult::ALLOW, $this->getName(), null, [ + 'current' => (int) $res['current'], + 'max' => $this->limit, + 'remaining' => $remaining, + 'resetAt' => (int) $res['resetAt'], + ]); + } + + /** + * Build the counting key. Composite keys are hashed to a fixed length so + * they fit the DB column; a plain IP is kept readable for stats/debugging. + */ + private function resolveKey(RuleContext $context): string + { + if ($this->keyBy === 'user') { + $uid = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; + if ($uid > 0) { + return 'u' . $uid; + } + return $context->ip; // anonymous → fall back to IP + } + + if ($this->keyBy === 'ip_route') { + $path = explode('?', $context->path, 2)[0]; + return substr(sha1($context->ip . '|' . $path), 0, 40); + } + + return $context->ip; + } + + /** + * Fixed-window check-and-increment via the DB. Increment first, then compare + * (node parity: count > max after increment → denied). + * + * @return array{allowed:bool,current:int,resetAt:int} + */ + private function checkFixed(string $key): array + { + $limiter = new WebDecoy_Rate_Limiter($this->limit, $this->window); + return $limiter->check_and_increment($key); + } + + /** + * Sliding-window via a two-bucket weighted approximation in the object cache. + * estimate = prev_bucket_count * overlap_fraction + current_bucket_count. + * + * @return array{allowed:bool,current:int,resetAt:int} + */ + private function checkSliding(string $key): array + { + $now = time(); + $win = $this->window; + $idx = intdiv($now, $win); + $currKey = $key . ':' . $idx; + $prevKey = $key . ':' . ($idx - 1); + + $curr = wp_cache_incr($currKey, 1, self::CACHE_GROUP); + if ($curr === false) { + wp_cache_add($currKey, 1, self::CACHE_GROUP, $win * 2); + $curr = 1; + } + + $prev = (int) wp_cache_get($prevKey, self::CACHE_GROUP); + $elapsed = $now % $win; + $weight = ($win - $elapsed) / $win; + $estimate = ($prev * $weight) + $curr; + + return [ + 'allowed' => $estimate <= $this->limit, + 'current' => (int) ceil($estimate), + 'resetAt' => ($idx + 1) * $win, + ]; + } +} diff --git a/includes/class-webdecoy-rate-limiter.php b/includes/class-webdecoy-rate-limiter.php index 7c4f2c7..b21ed33 100644 --- a/includes/class-webdecoy-rate-limiter.php +++ b/includes/class-webdecoy-rate-limiter.php @@ -72,6 +72,50 @@ public function is_exceeded(string $ip): bool return $count >= $this->limit; } + /** + * Fixed-window check-and-increment for an arbitrary key. Increments first, + * then reports whether the new count is within the limit (matches + * @webdecoy/node's RateLimitRule semantics). Used by the rate-limit rule. + * + * @param string $key IP or composite/hashed key + * @return array{allowed:bool,current:int,resetAt:int} + */ + public function check_and_increment(string $key): array + { + global $wpdb; + + $table = $wpdb->prefix . 'webdecoy_rate_limits'; + $now = current_time('mysql'); + $window_start_threshold = date('Y-m-d H:i:s', strtotime("-{$this->window} seconds")); + + $existing = $wpdb->get_row($wpdb->prepare( + "SELECT request_count, window_start FROM {$table} WHERE ip_address = %s AND window_start > %s", + $key, + $window_start_threshold + ), ARRAY_A); + + if ($existing) { + $current = (int) $existing['request_count'] + 1; + $wpdb->update($table, ['request_count' => $current], ['ip_address' => $key]); + $reset_at = strtotime($existing['window_start']) + $this->window; + } else { + $wpdb->delete($table, ['ip_address' => $key]); + $wpdb->insert($table, [ + 'ip_address' => $key, + 'request_count' => 1, + 'window_start' => $now, + ]); + $current = 1; + $reset_at = strtotime($now) + $this->window; + } + + return [ + 'allowed' => $current <= $this->limit, + 'current' => $current, + 'resetAt' => $reset_at, + ]; + } + /** * Increment request count for an IP * diff --git a/webdecoy.php b/webdecoy.php index 0457907..0c17aa2 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -206,6 +206,12 @@ private function load_options(): void 'rate_limit_enabled' => true, 'rate_limit_requests' => 60, 'rate_limit_window' => 60, + // Algorithm: fixed window (DB) or sliding window (object cache when a + // persistent one is present, else fixed). Key: per IP, IP+route, or + // logged-in user. Dry-run records without throttling. + 'rate_limit_algorithm' => 'fixed', + 'rate_limit_key' => 'ip', + 'rate_limit_dry_run' => false, // Tripwires (F4 deception layer). Deterministic, zero-false-positive: // a request for a scanner-bait honeypot path is automated by @@ -751,6 +757,7 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php'; if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; @@ -837,15 +844,9 @@ public function early_check(): void } } - // Check rate limit - if ($this->options['rate_limit_enabled']) { - $rateLimiter = new WebDecoy_Rate_Limiter(); - if ($rateLimiter->is_exceeded($ip)) { - $this->handle_rate_limit_exceeded($ip); - return; - } - $rateLimiter->increment($ip); - } + // Rate limiting now runs as a rule inside the engine above (THROTTLE → + // 429 + Retry-After + X-RateLimit-* headers), so it evaluates in order + // with tripwires and filters rather than as a separate pre-check. // Run bot detection with request path for MITRE ATT&CK path analysis $detector = $this->get_detector(); @@ -949,6 +950,18 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine } } + // Rate limiting runs last: a deterministic tripwire/filter DENY should + // win over a THROTTLE for the same request. + if (!empty($this->options['rate_limit_enabled'])) { + $rules[] = new WebDecoy_Rate_Limit_Rule([ + 'limit' => (int) ($this->options['rate_limit_requests'] ?? 60), + 'window' => (int) ($this->options['rate_limit_window'] ?? 60), + 'algorithm' => $this->options['rate_limit_algorithm'] ?? 'fixed', + 'keyBy' => $this->options['rate_limit_key'] ?? 'ip', + 'dryRun' => !empty($this->options['rate_limit_dry_run']), + ]); + } + if ($rules === []) { return null; } @@ -1062,13 +1075,18 @@ private function collect_request_headers(): array private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, string $ip): void { if ($result->action === \WebDecoy\Rules\RuleResult::THROTTLE) { - $retryAfter = 60; - if (is_array($result->metadata) && isset($result->metadata['retryAfter'])) { - $retryAfter = max(1, intval($result->metadata['retryAfter'])); - } + $meta = is_array($result->metadata) ? $result->metadata : []; + $retryAfter = isset($meta['retryAfter']) ? max(1, intval($meta['retryAfter'])) : 60; nocache_headers(); status_header(429); header('Retry-After: ' . $retryAfter); + if (isset($meta['max'])) { + header('X-RateLimit-Limit: ' . (int) $meta['max']); + header('X-RateLimit-Remaining: ' . (int) ($meta['remaining'] ?? 0)); + if (isset($meta['resetAt'])) { + header('X-RateLimit-Reset: ' . (int) $meta['resetAt']); + } + } wp_die( esc_html__('Too many requests. Please try again later.', 'webdecoy'), esc_html__('Too Many Requests', 'webdecoy'), @@ -1267,28 +1285,6 @@ private function serve_challenge_page(string $ip): void exit; } - /** - * Handle rate limit exceeded - * - * @param string $ip - */ - private function handle_rate_limit_exceeded(string $ip): void - { - // Add rate exceeded flag to detection - $detector = $this->get_detector(); - $result = $detector->analyze(['rate_exceeded' => true]); - - // Log rate limit exceeded - $this->log_detection($result, $ip); - - if ($result->getScore() >= $this->options['min_score_to_block']) { - $this->handle_blocking($result, $ip); - } else { - // Just block temporarily for rate limiting - $this->block_request(__('Too many requests. Please try again later.', 'webdecoy')); - } - } - /** * Block a request * @@ -1719,6 +1715,9 @@ public function sanitize_options(array $input): array $sanitized['rate_limit_enabled'] = !empty($input['rate_limit_enabled']); $sanitized['rate_limit_requests'] = max(1, intval($input['rate_limit_requests'] ?? 60)); $sanitized['rate_limit_window'] = max(1, intval($input['rate_limit_window'] ?? 60)); + $sanitized['rate_limit_algorithm'] = in_array($input['rate_limit_algorithm'] ?? 'fixed', ['fixed', 'sliding'], true) ? $input['rate_limit_algorithm'] : 'fixed'; + $sanitized['rate_limit_key'] = in_array($input['rate_limit_key'] ?? 'ip', ['ip', 'ip_route', 'user'], true) ? $input['rate_limit_key'] : 'ip'; + $sanitized['rate_limit_dry_run'] = !empty($input['rate_limit_dry_run']); // Tripwires $sanitized['tripwire_enabled'] = !empty($input['tripwire_enabled']);