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

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""" 

6 

7import os 

8import time 

9import logging 

10from collections import defaultdict 

11from functools import wraps 

12from typing import Optional 

13 

14from flask import request, jsonify, g 

15 

16logger = logging.getLogger('hevolve_security') 

17 

18 

19class RedisRateLimiter: 

20 """ 

21 Rate limiter with Redis backend and in-memory fallback. 

22 Uses sliding window counter algorithm. 

23 """ 

24 

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 } 

84 

85 def __init__(self): 

86 self._redis = None 

87 self._memory_store: dict = defaultdict(list) 

88 self._init_redis() 

89 

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}") 

107 

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' 

112 

113 if user_id: 

114 return f"rl:{action}:user:{user_id}" 

115 return f"rl:{action}:ip:{ip}" 

116 

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) 

124 

125 if self._redis: 

126 return self._check_redis(key, max_requests, window) 

127 return self._check_memory(key, max_requests, window) 

128 

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() 

138 

139 current_count = results[1] 

140 if current_count >= max_requests: 

141 return False 

142 

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) 

151 

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 

163 

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 

168 

169 

170# Singleton instance 

171_limiter = None 

172 

173 

174def get_rate_limiter() -> RedisRateLimiter: 

175 global _limiter 

176 if _limiter is None: 

177 _limiter = RedisRateLimiter() 

178 return _limiter 

179 

180 

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