| @@ -1,13 +1,12 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * MetaSync Rate Limiter |
| 4 | 4 | * |
| 5 | - * A robust rate limiting implementation that uses direct database storage | |
| 6 | - * to ensure consistent behavior across all server configurations including: | |
| 7 | - * - Object caching (Redis, Memcached) | |
| 8 | - * - Load-balanced environments | |
| 9 | - * - Server-side page caching | |
| 5 | + * A robust rate limiting implementation that uses per-key WordPress | |
| 6 | + * transients so each IP/token hash is stored as its own row with a | |
| 7 | + * TTL matching the rate-limit window. This avoids the contention, | |
| 8 | + * race conditions, and unbounded growth of a single serialized blob. | |
| 10 | 9 | * |
| 11 | 10 | * @package Metasync |
| 12 | 11 | * @subpackage Metasync/includes |
| 13 | 12 | * @since 2.5.17 |
| @@ -20,11 +19,11 @@ | ||
| 20 | 19 | |
| 21 | 20 | class Metasync_Rate_Limiter |
| 22 | 21 | { |
| 23 | 22 | /** |
| 24 | - * Option name for storing rate limit data | |
| 23 | + * Prefix for per-key rate limit transients. | |
| 25 | 24 | */ |
| 26 | - const OPTION_NAME = 'metasync_rate_limits'; | |
| 25 | + const TRANSIENT_PREFIX = 'metasync_rl_'; | |
| 27 | 26 | |
| 28 | 27 | /** |
| 29 | 28 | * Singleton instance |
| 30 | 29 | * |
| @@ -49,53 +48,63 @@ | ||
| 49 | 48 | * Private constructor for singleton pattern |
| 50 | 49 | */ |
| 51 | 50 | private function __construct() |
| 52 | 51 | { |
| 53 | - # Schedule cleanup if not already scheduled | |
| 54 | - if (!wp_next_scheduled('metasync_rate_limit_cleanup')) { | |
| 55 | - wp_schedule_event(time(), 'hourly', 'metasync_rate_limit_cleanup'); | |
| 56 | - } | |
| 52 | + # Cancel any previously scheduled hourly cleanup job — transient | |
| 53 | + # expiry handles cleanup automatically now. | |
| 54 | + wp_clear_scheduled_hook('metasync_rate_limit_cleanup'); | |
| 57 | 55 | } |
| 58 | 56 | |
| 59 | 57 | /** |
| 60 | - * Check and increment rate limit for a given key | |
| 58 | + * Build the transient key for a given full rate-limit key. | |
| 61 | 59 | * |
| 62 | - * Uses atomic database operations to ensure consistency across | |
| 63 | - * all server configurations including object caching and load balancers. | |
| 60 | + * Hashing keeps the resulting transient name comfortably under | |
| 61 | + * WordPress's 172-char limit regardless of the input length. | |
| 64 | 62 | * |
| 65 | - * @param string $key Unique identifier for rate limiting (e.g., hashed token or IP) | |
| 66 | - * @param int $max_attempts Maximum number of attempts allowed | |
| 63 | + * @param string $full_key Full rate-limit key (prefix + key). | |
| 64 | + * @return string Transient name. | |
| 65 | + */ | |
| 66 | + private function get_transient_key($full_key) | |
| 67 | + { | |
| 68 | + return self::TRANSIENT_PREFIX . substr(hash('sha256', $full_key), 0, 40); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /** | |
| 72 | + * Check and increment rate limit for a given key. | |
| 73 | + * | |
| 74 | + * Each key is tracked in its own transient, so concurrent | |
| 75 | + * requests for different keys never share a write lock. | |
| 76 | + * | |
| 77 | + * @param string $key Unique identifier for rate limiting (e.g., hashed token or IP) | |
| 78 | + * @param int $max_attempts Maximum number of attempts allowed | |
| 67 | 79 | * @param int $window_seconds Time window in seconds |
| 68 | - * @param string $prefix Optional prefix for the rate limit key | |
| 80 | + * @param string $prefix Optional prefix for the rate limit key | |
| 69 | 81 | * @return bool|WP_Error True if under limit, WP_Error if rate limit exceeded |
| 70 | 82 | */ |
| 71 | 83 | public function check_rate_limit($key, $max_attempts, $window_seconds, $prefix = '') |
| 72 | 84 | { |
| 73 | - global $wpdb; | |
| 85 | + $full_key = $prefix . $key; | |
| 86 | + $transient_key = $this->get_transient_key($full_key); | |
| 87 | + $now = time(); | |
| 74 | 88 | |
| 75 | - $full_key = $prefix . $key; | |
| 76 | - $now = time(); | |
| 89 | + $data = get_transient($transient_key); | |
| 77 | 90 | |
| 78 | - # Get current rate limit data with row-level locking for atomicity | |
| 79 | - # Using direct database query instead of get_option for reliability | |
| 80 | - $data = $this->get_rate_limit_data(); | |
| 81 | - | |
| 82 | - # Initialize entry if not exists or expired | |
| 83 | - if (!isset($data[$full_key]) || $data[$full_key]['expires_at'] < $now) { | |
| 84 | - $data[$full_key] = array( | |
| 85 | - 'attempts' => 1, | |
| 91 | + # Initialize entry if not present or expired | |
| 92 | + if ($data === false || !is_array($data) || !isset($data['expires_at']) || $data['expires_at'] < $now) { | |
| 93 | + $data = array( | |
| 94 | + 'attempts' => 1, | |
| 86 | 95 | 'first_attempt_at' => $now, |
| 87 | - 'expires_at' => $now + $window_seconds, | |
| 88 | - 'window_seconds' => $window_seconds, | |
| 96 | + 'expires_at' => $now + $window_seconds, | |
| 97 | + 'window_seconds' => $window_seconds, | |
| 89 | 98 | ); |
| 90 | - $this->save_rate_limit_data($data); | |
| 99 | + set_transient($transient_key, $data, $window_seconds); | |
| 91 | 100 | |
| 92 | 101 | return true; |
| 93 | 102 | } |
| 94 | 103 | |
| 95 | 104 | # Check if rate limit exceeded |
| 96 | - if ($data[$full_key]['attempts'] >= $max_attempts) { | |
| 97 | - $remaining_seconds = $data[$full_key]['expires_at'] - $now; | |
| 105 | + if ($data['attempts'] >= $max_attempts) { | |
| 106 | + $remaining_seconds = $data['expires_at'] - $now; | |
| 98 | 107 | $remaining_minutes = ceil($remaining_seconds / 60); |
| 99 | 108 | |
| 100 | 109 | return new WP_Error( |
| 101 | 110 | 'rate_limit_exceeded', |
| @@ -105,18 +114,18 @@ | ||
| 105 | 114 | $remaining_minutes > 1 ? 's' : '' |
| 106 | 115 | ), |
| 107 | 116 | array( |
| 108 | 117 | 'retry_after' => $remaining_seconds, |
| 109 | - 'attempts' => $data[$full_key]['attempts'], | |
| 118 | + 'attempts' => $data['attempts'], | |
| 110 | 119 | 'max_attempts' => $max_attempts, |
| 111 | 120 | ) |
| 112 | 121 | ); |
| 113 | 122 | } |
| 114 | 123 | |
| 115 | - # Increment attempt count | |
| 116 | - $data[$full_key]['attempts']++; | |
| 117 | - $data[$full_key]['last_attempt_at'] = $now; | |
| 118 | - $this->save_rate_limit_data($data); | |
| 124 | + # Increment attempt count, preserving the original window end | |
| 125 | + $data['attempts']++; | |
| 126 | + $data['last_attempt_at'] = $now; | |
| 127 | + set_transient($transient_key, $data, max(1, $data['expires_at'] - $now)); | |
| 119 | 128 | |
| 120 | 129 | return true; |
| 121 | 130 | } |
| 122 | 131 | |
| @@ -172,94 +181,8 @@ | ||
| 172 | 181 | return $this->check_rate_limit($token_hash, $max_attempts, $window_seconds, 'token_'); |
| 173 | 182 | } |
| 174 | 183 | |
| 175 | 184 | /** |
| 176 | - * Get rate limit data from database | |
| 177 | - * | |
| 178 | - * Uses direct SQL query to bypass object caching and ensure | |
| 179 | - * we always get the latest data from the database. | |
| 180 | - * | |
| 181 | - * @return array Rate limit data | |
| 182 | - */ | |
| 183 | - private function get_rate_limit_data() | |
| 184 | - { | |
| 185 | - global $wpdb; | |
| 186 | - | |
| 187 | - # Bypass object cache by using direct SQL query | |
| 188 | - $value = $wpdb->get_var( | |
| 189 | - $wpdb->prepare( | |
| 190 | - "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", | |
| 191 | - self::OPTION_NAME | |
| 192 | - ) | |
| 193 | - ); | |
| 194 | - | |
| 195 | - if ($value === null) { | |
| 196 | - return array(); | |
| 197 | - } | |
| 198 | - | |
| 199 | - $data = maybe_unserialize($value); | |
| 200 | - return is_array($data) ? $data : array(); | |
| 201 | - } | |
| 202 | - | |
| 203 | - /** | |
| 204 | - * Save rate limit data to database | |
| 205 | - * | |
| 206 | - * Uses direct SQL query with ON DUPLICATE KEY UPDATE for atomicity. | |
| 207 | - * | |
| 208 | - * @param array $data Rate limit data | |
| 209 | - * @return bool Success | |
| 210 | - */ | |
| 211 | - private function save_rate_limit_data($data) | |
| 212 | - { | |
| 213 | - global $wpdb; | |
| 214 | - | |
| 215 | - $serialized = maybe_serialize($data); | |
| 216 | - | |
| 217 | - # Use REPLACE to ensure atomic update (INSERT or UPDATE) | |
| 218 | - $result = $wpdb->query( | |
| 219 | - $wpdb->prepare( | |
| 220 | - "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) | |
| 221 | - VALUES (%s, %s, 'no') | |
| 222 | - ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", | |
| 223 | - self::OPTION_NAME, | |
| 224 | - $serialized | |
| 225 | - ) | |
| 226 | - ); | |
| 227 | - | |
| 228 | - # Clear any object cache for this option | |
| 229 | - wp_cache_delete(self::OPTION_NAME, 'options'); | |
| 230 | - | |
| 231 | - return $result !== false; | |
| 232 | - } | |
| 233 | - | |
| 234 | - /** | |
| 235 | - * Clean up expired rate limit entries | |
| 236 | - * | |
| 237 | - * Called via cron job to prevent database bloat. | |
| 238 | - * | |
| 239 | - * @return int Number of entries cleaned up | |
| 240 | - */ | |
| 241 | - public function cleanup_expired_entries() | |
| 242 | - { | |
| 243 | - $data = $this->get_rate_limit_data(); | |
| 244 | - $now = time(); | |
| 245 | - $original_count = count($data); | |
| 246 | - | |
| 247 | - # Remove expired entries | |
| 248 | - $data = array_filter($data, function ($entry) use ($now) { | |
| 249 | - return isset($entry['expires_at']) && $entry['expires_at'] > $now; | |
| 250 | - }); | |
| 251 | - | |
| 252 | - $removed_count = $original_count - count($data); | |
| 253 | - | |
| 254 | - if ($removed_count > 0) { | |
| 255 | - $this->save_rate_limit_data($data); | |
| 256 | - } | |
| 257 | - | |
| 258 | - return $removed_count; | |
| 259 | - } | |
| 260 | - | |
| 261 | - /** | |
| 262 | 185 | * Get client IP address |
| 263 | 186 | * |
| 264 | 187 | * Handles various proxy configurations and load balancers. |
| 265 | 188 | * |
| @@ -304,16 +227,9 @@ | ||
| 304 | 227 | * @return bool Success |
| 305 | 228 | */ |
| 306 | 229 | public function reset_rate_limit($key, $prefix = '') |
| 307 | 230 | { |
| 308 | - $full_key = $prefix . $key; | |
| 309 | - $data = $this->get_rate_limit_data(); | |
| 310 | - | |
| 311 | - if (isset($data[$full_key])) { | |
| 312 | - unset($data[$full_key]); | |
| 313 | - return $this->save_rate_limit_data($data); | |
| 314 | - } | |
| 315 | - | |
| 231 | + delete_transient($this->get_transient_key($prefix . $key)); | |
| 316 | 232 | return true; |
| 317 | 233 | } |
| 318 | 234 | |
| 319 | 235 | /** |
| @@ -324,32 +240,22 @@ | ||
| 324 | 240 | * @return array|null Rate limit status or null if not found |
| 325 | 241 | */ |
| 326 | 242 | public function get_rate_limit_status($key, $prefix = '') |
| 327 | 243 | { |
| 328 | - $full_key = $prefix . $key; | |
| 329 | - $data = $this->get_rate_limit_data(); | |
| 244 | + $data = get_transient($this->get_transient_key($prefix . $key)); | |
| 330 | 245 | |
| 331 | - if (!isset($data[$full_key])) { | |
| 246 | + if ($data === false || !is_array($data)) { | |
| 332 | 247 | return null; |
| 333 | 248 | } |
| 334 | 249 | |
| 335 | - $entry = $data[$full_key]; | |
| 336 | 250 | $now = time(); |
| 337 | 251 | |
| 338 | 252 | return array( |
| 339 | - 'attempts' => $entry['attempts'], | |
| 340 | - 'expires_at' => $entry['expires_at'], | |
| 341 | - 'remaining_seconds' => max(0, $entry['expires_at'] - $now), | |
| 342 | - 'is_expired' => $entry['expires_at'] < $now, | |
| 343 | - 'first_attempt_at' => $entry['first_attempt_at'], | |
| 344 | - 'last_attempt_at' => isset($entry['last_attempt_at']) ? $entry['last_attempt_at'] : null, | |
| 253 | + 'attempts' => $data['attempts'], | |
| 254 | + 'expires_at' => $data['expires_at'], | |
| 255 | + 'remaining_seconds' => max(0, $data['expires_at'] - $now), | |
| 256 | + 'is_expired' => $data['expires_at'] < $now, | |
| 257 | + 'first_attempt_at' => $data['first_attempt_at'], | |
| 258 | + 'last_attempt_at' => isset($data['last_attempt_at']) ? $data['last_attempt_at'] : null, | |
| 345 | 259 | ); |
| 346 | 260 | } |
| 347 | 261 | } |
| 348 | - | |
| 349 | -/** | |
| 350 | - * Hook for cron cleanup | |
| 351 | - */ | |
| 352 | -add_action('metasync_rate_limit_cleanup', function () { | |
| 353 | - $rate_limiter = Metasync_Rate_Limiter::get_instance(); | |
| 354 | - $rate_limiter->cleanup_expired_entries(); | |
| 355 | -}); | |