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
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1import ipaddress
2import re
3import uuid
5from django.conf import settings
6from django.http import JsonResponse
9def get_client_ip(request):
10 """Extract the real client IP for django-ratelimit.
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.
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.
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
36 return request.META.get("REMOTE_ADDR", "127.0.0.1")
39class RequestIDMiddleware:
40 """Attach a unique request_id to each request for log correlation."""
42 def __init__(self, get_response):
43 self.get_response = get_response
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
52class DeviceDetectionMiddleware:
53 """Middleware to detect legacy browsers that can't run modern React.
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
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 """
66 # Pattern to match iOS version from user agent
67 IOS_PATTERN = re.compile(r"(?:iPhone|iPad|iPod).*OS (\d+)_")
69 # Pattern to match Chrome version
70 CHROME_PATTERN = re.compile(r"Chrome/(\d+)\.")
72 # Pattern to match Firefox version
73 FIREFOX_PATTERN = re.compile(r"Firefox/(\d+)\.")
75 def __init__(self, get_response):
76 self.get_response = get_response
78 def __call__(self, request):
79 request.is_legacy_device = self._is_legacy_device(request)
80 return self.get_response(request)
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
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)
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
102 def _is_internet_explorer(self, ua):
103 """Internet Explorer (all versions)."""
104 return "MSIE " in ua or "Trident/" in ua
106 def _is_edge_legacy(self, ua):
107 """Edge Legacy (non-Chromium, pre-2020)."""
108 return "Edge/" in ua and "Edg/" not in ua
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
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
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}/?$")
131_home_only_patterns_cache: tuple[re.Pattern, ...] | None = None
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
146 from apps.core.auth import HomeOnlyAuth
147 from cookie.urls import api
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}")
160 _home_only_patterns_cache = tuple(_ninja_path_to_regex(p) for p in paths)
161 return _home_only_patterns_cache
164class HomeOnlyRouteGateMiddleware:
165 """Short-circuit every method on HomeOnlyAuth-gated routes to 404 in
166 non-home auth modes, above Django's URL dispatcher.
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 """
177 def __init__(self, get_response):
178 self.get_response = get_response
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)
189class MethodNotAllowedToNotFoundMiddleware:
190 """Rewrite every HTTP 405 response to a JSON 404.
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).
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.
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 """
213 def __init__(self, get_response):
214 self.get_response = get_response
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