Coverage for security / rate_limiter_redis.py: 60.0%
75 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Redis-backed Distributed Rate Limiter
3Sliding window counter with composite key (user_id + IP).
4Falls back to in-memory when Redis is unavailable.
5"""
7import os
8import time
9import logging
10from collections import defaultdict
11from functools import wraps
12from typing import Optional
14from flask import request, jsonify, g
16logger = logging.getLogger('hevolve_security')
19class RedisRateLimiter:
20 """
21 Rate limiter with Redis backend and in-memory fallback.
22 Uses sliding window counter algorithm.
23 """
25 # Default limits per action type
26 LIMITS = {
27 'global': (60, 60), # 60 requests per 60 seconds
28 'auth': (10, 60), # 10 auth attempts per 60 seconds
29 'search': (30, 60), # 30 searches per 60 seconds
30 'post': (10, 60), # 10 posts per 60 seconds
31 'comment': (20, 60), # 20 comments per 60 seconds
32 'vote': (60, 60), # 60 votes per 60 seconds
33 'bot_register': (5, 300), # 5 registrations per 5 minutes
34 'discover': (10, 60), # 10 discovery calls per 60 seconds
35 'chat': (30, 60), # 30 chat requests per 60 seconds
36 'goal_create': (10, 3600), # 10 goals per user per hour
37 'remote_desktop': (30, 60), # 30 connections per 60 seconds
38 'remote_desktop_auth': (5, 60), # 5 failed auth attempts per 60 seconds
39 'shell_ops': (30, 60), # 30 shell operations per 60 seconds
40 'shell_file_ops': (20, 60), # 20 file operations per 60 seconds
41 'shell_terminal': (10, 60), # 10 terminal sessions per 60 seconds
42 'shell_power': (3, 60), # 3 power actions per 60 seconds
43 'app_install': (5, 3600), # 5 installs per hour
44 'sharing': (20, 60), # 20 shares per 60 seconds
45 'gamification': (30, 60), # 30 gamification calls per 60 seconds
46 'games': (20, 60), # 20 game operations per 60 seconds
47 'mcp': (30, 60), # 30 MCP operations per 60 seconds
48 'tts': (10, 60), # 10 TTS generations per 60 seconds
49 'tts_speak': (20, 60), # 20 TTS speak requests per 60 seconds
50 'tts_clone': (5, 3600), # 5 voice clones per hour
51 'civic_sentinel': (20, 60), # 20 civic sentinel ops per 60 seconds
52 'p2p_rideshare': (30, 60), # 30 ride ops per 60 seconds
53 'p2p_marketplace': (30, 60), # 30 marketplace ops per 60 seconds
54 'p2p_grocery': (20, 60), # 20 grocery ops per 60 seconds
55 'p2p_food': (20, 60), # 20 food delivery ops per 60 seconds
56 'p2p_bills': (15, 60), # 15 bill payments per 60 seconds
57 'p2p_tickets': (15, 60), # 15 ticket bookings per 60 seconds
58 'p2p_freelance': (20, 60), # 20 freelance ops per 60 seconds
59 'p2p_tutoring': (20, 60), # 20 tutoring ops per 60 seconds
60 'p2p_services': (20, 60), # 20 service ops per 60 seconds
61 'p2p_rental': (20, 60), # 20 rental ops per 60 seconds
62 'p2p_health': (15, 60), # 15 health ops per 60 seconds
63 'p2p_logistics': (20, 60), # 20 logistics ops per 60 seconds
64 'autoresearch': (5, 3600), # 5 autoresearch sessions per hour
65 'wifi': (30, 60), # 30 wifi operations per 60 seconds
66 'vpn': (20, 60), # 20 vpn operations per 60 seconds
67 'trash': (30, 60), # 30 trash operations per 60 seconds
68 'battery': (60, 60), # 60 battery queries per 60 seconds
69 'webcam': (10, 60), # 10 webcam operations per 60 seconds
70 'scanner': (5, 60), # 5 scanner operations per 60 seconds
71 'video_gen': (5, 300), # 5 video generations per 5 minutes
72 'keyboard': (20, 60), # 20 keyboard operations per 60 seconds
73 'app_permissions': (10, 60), # 10 permission changes per 60 seconds
74 'file_tags': (30, 60), # 30 file tag operations per 60 seconds
75 'hotspot': (10, 60), # 10 hotspot operations per 60 seconds
76 'weather': (10, 60), # 10 weather queries per 60 seconds
77 'auto_update': (3, 3600), # 3 update runs per hour
78 'dns': (5, 60), # 5 DNS config changes per 60 seconds
79 'sso': (5, 60), # 5 SSO operations per 60 seconds
80 'email': (10, 60), # 10 email operations per 60 seconds
81 'voice_control': (30, 60), # 30 voice operations per 60 seconds
82 'screen_rotation': (10, 60), # 10 rotation changes per 60 seconds
83 }
85 def __init__(self):
86 self._redis = None
87 self._memory_store: dict = defaultdict(list)
88 self._init_redis()
90 def _init_redis(self):
91 try:
92 import redis
93 redis_url = os.environ.get(
94 'REDIS_RATE_LIMIT_URL',
95 os.environ.get('REDIS_URL', 'redis://localhost:6379/1')
96 )
97 self._redis = redis.from_url(
98 redis_url, decode_responses=True,
99 socket_timeout=3, socket_connect_timeout=2,
100 socket_keepalive=True, retry_on_timeout=True,
101 )
102 self._redis.ping()
103 logger.info("Redis rate limiter connected")
104 except Exception as e:
105 self._redis = None
106 logger.info(f"Redis unavailable, using in-memory rate limiter: {e}")
108 def _get_key(self, action: str) -> str:
109 """Build composite rate limit key from user_id + IP."""
110 user_id = getattr(g, 'user_id', None) if hasattr(g, 'user_id') else None
111 ip = request.remote_addr or 'unknown'
113 if user_id:
114 return f"rl:{action}:user:{user_id}"
115 return f"rl:{action}:ip:{ip}"
117 def check(self, action: str = 'global') -> bool:
118 """
119 Check if request is within rate limit.
120 Returns True if allowed, False if rate limited.
121 """
122 max_requests, window = self.LIMITS.get(action, self.LIMITS['global'])
123 key = self._get_key(action)
125 if self._redis:
126 return self._check_redis(key, max_requests, window)
127 return self._check_memory(key, max_requests, window)
129 def _check_redis(self, key: str, max_requests: int, window: int) -> bool:
130 """Redis sliding window counter."""
131 try:
132 now = time.time()
133 # Step 1: clean old entries and count current
134 pipe = self._redis.pipeline()
135 pipe.zremrangebyscore(key, 0, now - window)
136 pipe.zcard(key)
137 results = pipe.execute()
139 current_count = results[1]
140 if current_count >= max_requests:
141 return False
143 # Step 2: only record if under limit
144 self._redis.zadd(key, {str(now): now})
145 self._redis.expire(key, window + 1)
146 return True
147 except Exception as e:
148 logger.warning(f"Redis rate limit check failed, falling back to memory: {e}")
149 # Fail-closed: fall back to in-memory limiter, NOT open allow
150 return self._check_memory(key, max_requests, window)
152 def _check_memory(self, key: str, max_requests: int, window: int) -> bool:
153 """In-memory sliding window (fallback)."""
154 now = time.time()
155 # Clean old entries
156 self._memory_store[key] = [
157 t for t in self._memory_store[key] if t > now - window
158 ]
159 if len(self._memory_store[key]) >= max_requests:
160 return False
161 self._memory_store[key].append(now)
162 return True
164 def get_retry_after(self, action: str = 'global') -> int:
165 """Get seconds until rate limit resets."""
166 _, window = self.LIMITS.get(action, self.LIMITS['global'])
167 return window
170# Singleton instance
171_limiter = None
174def get_rate_limiter() -> RedisRateLimiter:
175 global _limiter
176 if _limiter is None:
177 _limiter = RedisRateLimiter()
178 return _limiter
181def rate_limit(action: str = 'global'):
182 """
183 Flask decorator for rate limiting.
184 Usage: @rate_limit('search')
185 """
186 def decorator(f):
187 @wraps(f)
188 def decorated(*args, **kwargs):
189 limiter = get_rate_limiter()
190 if not limiter.check(action):
191 retry_after = limiter.get_retry_after(action)
192 response = jsonify({
193 'error': 'Rate limit exceeded',
194 'retry_after': retry_after,
195 })
196 response.status_code = 429
197 response.headers['Retry-After'] = str(retry_after)
198 return response
199 return f(*args, **kwargs)
200 return decorated
201 return decorator