|
| 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 | +} |
0 commit comments