Coverage for apps / core / middleware.py: 98%

93 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-02 13:22 +0000

1import ipaddress 

2import re 

3import uuid 

4 

5from django.conf import settings 

6from django.http import JsonResponse 

7 

8 

9def get_client_ip(request): 

10 """Extract the real client IP for django-ratelimit. 

11 

12 Uses rightmost X-Forwarded-For entry as the authoritative source: 

13 - Cloudflare strips any client-injected X-Forwarded-For entries and 

14 appends the real client IP; Traefik trusts Cloudflare's XFF via 

15 forwardedHeaders.trustedIPs, so the rightmost entry is always the 

16 real client IP as seen by Cloudflare. 

17 - REMOTE_ADDR is the final fallback for local dev / direct traffic. 

18 

19 CF-Connecting-IP was intentionally removed (pentest round 10, F-26): 

20 Cloudflare does NOT strip client-injected CF-Connecting-IP headers 

21 before adding its own — an attacker can inject an arbitrary value and 

22 bypass rate limiting entirely. Do NOT re-add CF-Connecting-IP. 

23 

24 Used via RATELIMIT_IP_META_KEY = "apps.core.middleware.get_client_ip" 

25 """ 

26 forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "") 

27 if forwarded_for: 

28 entries = [e.strip() for e in forwarded_for.split(",")] 

29 for candidate in reversed(entries): 

30 try: 

31 ipaddress.ip_address(candidate) 

32 return candidate 

33 except ValueError: 

34 continue 

35 

36 return request.META.get("REMOTE_ADDR", "127.0.0.1") 

37 

38 

39class RequestIDMiddleware: 

40 """Attach a unique request_id to each request for log correlation.""" 

41 

42 def __init__(self, get_response): 

43 self.get_response = get_response 

44 

45 def __call__(self, request): 

46 request.request_id = str(uuid.uuid4())[:8] 

47 response = self.get_response(request) 

48 response["X-Request-ID"] = request.request_id 

49 return response 

50 

51 

52class DeviceDetectionMiddleware: 

53 """Middleware to detect legacy browsers that can't run modern React. 

54 

55 Sets request.is_legacy_device = True for browsers that need the legacy frontend: 

56 - iOS < 11 (Safari lacks ES6 module support) 

57 - Internet Explorer (all versions) 

58 - Edge Legacy (non-Chromium, pre-2020) 

59 - Chrome < 60 

60 - Firefox < 55 

61 

62 Note: Actual redirects are handled by Nginx for performance (see nginx/nginx.conf). 

63 This middleware provides the detection flag for use in views/templates if needed. 

64 """ 

65 

66 # Pattern to match iOS version from user agent 

67 IOS_PATTERN = re.compile(r"(?:iPhone|iPad|iPod).*OS (\d+)_") 

68 

69 # Pattern to match Chrome version 

70 CHROME_PATTERN = re.compile(r"Chrome/(\d+)\.") 

71 

72 # Pattern to match Firefox version 

73 FIREFOX_PATTERN = re.compile(r"Firefox/(\d+)\.") 

74 

75 def __init__(self, get_response): 

76 self.get_response = get_response 

77 

78 def __call__(self, request): 

79 request.is_legacy_device = self._is_legacy_device(request) 

80 return self.get_response(request) 

81 

82 def _is_legacy_device(self, request): 

83 """Check if the request is from a browser that can't run modern React.""" 

84 user_agent = request.META.get("HTTP_USER_AGENT", "") 

85 if not user_agent: 

86 return False 

87 

88 detectors = [ 

89 self._is_legacy_ios, 

90 self._is_internet_explorer, 

91 self._is_edge_legacy, 

92 self._is_old_chrome, 

93 self._is_old_firefox, 

94 ] 

95 return any(detect(user_agent) for detect in detectors) 

96 

97 def _is_legacy_ios(self, ua): 

98 """iOS < 11 (Safari lacks ES6 module support).""" 

99 match = self.IOS_PATTERN.search(ua) 

100 return match is not None and int(match.group(1)) < 11 

101 

102 def _is_internet_explorer(self, ua): 

103 """Internet Explorer (all versions).""" 

104 return "MSIE " in ua or "Trident/" in ua 

105 

106 def _is_edge_legacy(self, ua): 

107 """Edge Legacy (non-Chromium, pre-2020).""" 

108 return "Edge/" in ua and "Edg/" not in ua 

109 

110 def _is_old_chrome(self, ua): 

111 """Chrome < 60 (excluding Edge and Opera).""" 

112 if "Chrome/" not in ua or "Edg" in ua or "OPR/" in ua: 

113 return False 

114 match = self.CHROME_PATTERN.search(ua) 

115 return match is not None and int(match.group(1)) < 60 

116 

117 def _is_old_firefox(self, ua): 

118 """Firefox < 55.""" 

119 match = self.FIREFOX_PATTERN.search(ua) 

120 return match is not None and int(match.group(1)) < 55 

121 

122 

123def _ninja_path_to_regex(path: str) -> re.Pattern: 

124 """Convert a Ninja URL path (with `{param}` / `{param:int}` placeholders) 

125 to a compiled regex that matches a single URL, with an optional trailing 

126 slash. A `{param}` spans exactly one path segment (no slashes).""" 

127 pattern = re.sub(r"\{[^/}]+\}", r"[^/]+", path.rstrip("/")) 

128 return re.compile(rf"^{pattern}/?$") 

129 

130 

131_home_only_patterns_cache: tuple[re.Pattern, ...] | None = None 

132 

133 

134def _home_only_patterns() -> tuple[re.Pattern, ...]: 

135 """Introspect the Ninja API once to compile regexes for every path 

136 whose registered methods ALL use `HomeOnlyAuth`. A path that mixes 

137 `HomeOnlyAuth` methods with `SessionAuth` methods (e.g. `/api/ai/quotas` 

138 has GET=SessionAuth + PUT=HomeOnlyAuth) is NOT included here — those 

139 paths must remain reachable in passkey mode for the non-gated methods. 

140 Lazy-imported to avoid a circular import at module load (urls → ninja 

141 routers → auth classes → middleware).""" 

142 global _home_only_patterns_cache 

143 if _home_only_patterns_cache is not None: 

144 return _home_only_patterns_cache 

145 

146 from apps.core.auth import HomeOnlyAuth 

147 from cookie.urls import api 

148 

149 paths: set[str] = set() 

150 for prefix, router in api._routers: 

151 for path, path_op in router.path_operations.items(): 

152 if not path_op.operations: 

153 continue 

154 all_home_only = all( 

155 any(isinstance(a, HomeOnlyAuth) for a in (op.auth_callbacks or [])) for op in path_op.operations 

156 ) 

157 if all_home_only: 

158 paths.add(f"/api{prefix}{path}") 

159 

160 _home_only_patterns_cache = tuple(_ninja_path_to_regex(p) for p in paths) 

161 return _home_only_patterns_cache 

162 

163 

164class HomeOnlyRouteGateMiddleware: 

165 """Short-circuit every method on HomeOnlyAuth-gated routes to 404 in 

166 non-home auth modes, above Django's URL dispatcher. 

167 

168 Without this, HEAD/OPTIONS (and other unregistered methods) on gated 

169 routes fall through to Django's 405 handler, which emits an `Allow:` 

170 header listing the real method set — leaking route existence to 

171 unauthenticated probes. The v1.42.0 security posture promises that 

172 gated routes are "indistinguishable from never-existed paths"; this 

173 middleware upholds that promise for every verb, not just those with 

174 a registered Ninja handler. 

175 """ 

176 

177 def __init__(self, get_response): 

178 self.get_response = get_response 

179 

180 def __call__(self, request): 

181 if settings.AUTH_MODE != "home": 

182 path = request.path 

183 for pattern in _home_only_patterns(): 

184 if pattern.match(path): 

185 return JsonResponse({"detail": "Not found"}, status=404) 

186 return self.get_response(request) 

187 

188 

189class MethodNotAllowedToNotFoundMiddleware: 

190 """Rewrite every HTTP 405 response to a JSON 404. 

191 

192 Rationale: a 405 "Method Not Allowed" response tells a probe that the 

193 URL exists but doesn't accept this method. For POST-only endpoints 

194 like `/api/auth/logout/` and `/api/auth/device/authorize/`, an 

195 unauthenticated `GET` would otherwise return 405 — contradicting the 

196 v1.42.0 "gated paths are indistinguishable from never-existed paths" 

197 invariant that `HomeOnlyRouteGateMiddleware` established for 

198 HomeOnlyAuth routes (pentest round 6 / F-5). 

199 

200 The rewrite is global rather than per-endpoint because: 

201 - Cookie's clients (React frontend, legacy ES5 frontend) never rely 

202 on 405 for control flow — they only issue documented methods. 

203 - A global rule removes the possibility of forgetting to gate a new 

204 POST-only endpoint. 

205 - The existing `Allow:` header that Django attaches to 405 responses 

206 also leaks the method set; collapsing to 404 drops it. 

207 

208 Nginx-generated 405s (none occur in the current config, but a future 

209 location block could introduce one) are separately rewritten to 404 

210 via `error_page 405 =404 @not_found;` in `nginx/nginx.prod.conf`. 

211 """ 

212 

213 def __init__(self, get_response): 

214 self.get_response = get_response 

215 

216 def __call__(self, request): 

217 # TRACE is handled by nginx before it reaches gunicorn, so the 

218 # upstream 405 bypass error_page rewriting. Block it here as 

219 # defence-in-depth for any path where the request does reach Django. 

220 if request.method == "TRACE": 

221 return JsonResponse({"detail": "Not found"}, status=404) 

222 response = self.get_response(request) 

223 if response.status_code == 405: 

224 return JsonResponse({"detail": "Not found"}, status=404) 

225 return response 

← Back to Dashboard