authentik.root.middleware
Dynamically set SameSite depending if the upstream connection is TLS or not
1"""Dynamically set SameSite depending if the upstream connection is TLS or not""" 2 3from collections.abc import Callable 4from hashlib import sha512 5from ipaddress import ip_address 6from time import perf_counter, time 7from typing import Any 8 9from channels.exceptions import DenyConnection 10from django.conf import settings 11from django.contrib.sessions.backends.base import UpdateError 12from django.contrib.sessions.exceptions import SessionInterrupted 13from django.contrib.sessions.middleware import SessionMiddleware as UpstreamSessionMiddleware 14from django.http.request import HttpRequest 15from django.http.response import HttpResponse, HttpResponseServerError 16from django.middleware.csrf import CSRF_SESSION_KEY 17from django.middleware.csrf import CsrfViewMiddleware as UpstreamCsrfViewMiddleware 18from django.utils.cache import patch_vary_headers 19from django.utils.http import http_date 20from jwt import PyJWTError, decode, encode 21from sentry_sdk import Scope 22from structlog.stdlib import get_logger 23 24from authentik.core.models import Token, TokenIntents, User, UserTypes 25from authentik.lib.config import CONFIG 26 27LOGGER = get_logger("authentik.asgi") 28ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default" 29SIGNING_HASH = sha512(settings.SECRET_KEY.encode()).hexdigest() 30 31 32class SessionMiddleware(UpstreamSessionMiddleware): 33 """Dynamically set SameSite depending if the upstream connection is TLS or not""" 34 35 @staticmethod 36 def is_secure(request: HttpRequest) -> bool: 37 """Check if request is TLS'd or localhost""" 38 if request.is_secure(): 39 return True 40 host, _, _ = request.get_host().partition(":") 41 if host == "localhost" and settings.DEBUG: 42 # Since go does not consider localhost with http a secure origin 43 # we can't set the secure flag. 44 user_agent = request.META.get("HTTP_USER_AGENT", "") 45 if user_agent.startswith("goauthentik.io/outpost/") or ( 46 "safari" in user_agent.lower() and "chrome" not in user_agent.lower() 47 ): 48 return False 49 return True 50 return False 51 52 @staticmethod 53 def decode_session_key(key: str | None) -> str | None: 54 """Decode raw session cookie, and parse JWT""" 55 # We need to support the standard django format of just a session key 56 # for testing setups, where the session is directly set 57 session_key = key if settings.TEST else None 58 try: 59 session_payload = decode(key, SIGNING_HASH, algorithms=["HS256"]) 60 session_key = session_payload["sid"] 61 except KeyError, PyJWTError: 62 pass 63 return session_key 64 65 @staticmethod 66 def encode_session(session_key: str, user: User): 67 payload = { 68 "sid": session_key, 69 "iss": "authentik", 70 "sub": "anonymous", 71 "authenticated": user.is_authenticated, 72 "acr": ACR_AUTHENTIK_SESSION, 73 } 74 if user.is_authenticated: 75 payload["sub"] = user.uid 76 value = encode(payload=payload, key=SIGNING_HASH) 77 if settings.TEST: 78 value = session_key 79 return value 80 81 def process_request(self, request: HttpRequest): 82 raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 83 session_key = SessionMiddleware.decode_session_key(raw_session) 84 request.session = self.SessionStore( 85 session_key, 86 last_ip=ClientIPMiddleware.get_client_ip(request), 87 last_user_agent=request.META.get("HTTP_USER_AGENT", ""), 88 ) 89 90 def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: 91 """ 92 If request.session was modified, or if the configuration is to save the 93 session every time, save the changes and set a session cookie or delete 94 the session cookie if the session has been emptied. 95 """ 96 try: 97 accessed = request.session.accessed 98 modified = request.session.modified 99 empty = request.session.is_empty() 100 except AttributeError: 101 return response 102 # Set SameSite based on whether or not the request is secure 103 secure = SessionMiddleware.is_secure(request) 104 same_site = "None" if secure else "Lax" 105 # First check if we need to delete this cookie. 106 # The session should be deleted only if the session is entirely empty. 107 if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: 108 response.delete_cookie( 109 settings.SESSION_COOKIE_NAME, 110 path=settings.SESSION_COOKIE_PATH, 111 domain=settings.SESSION_COOKIE_DOMAIN, 112 samesite=same_site, 113 ) 114 patch_vary_headers(response, ("Cookie",)) 115 else: 116 if accessed: 117 patch_vary_headers(response, ("Cookie",)) 118 if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: 119 if request.session.get_expire_at_browser_close(): 120 max_age = None 121 expires = None 122 else: 123 max_age = request.session.get_expiry_age() 124 expires_time = time() + max_age 125 expires = http_date(expires_time) 126 # Save the session data and refresh the client cookie. 127 # Skip session save for 500 responses, refs #3881. 128 if response.status_code != HttpResponseServerError.status_code: 129 try: 130 request.session.save() 131 except UpdateError: 132 raise SessionInterrupted( 133 "The request's session was deleted before the " 134 "request completed. The user may have logged " 135 "out in a concurrent request, for example." 136 ) from None 137 response.set_cookie( 138 settings.SESSION_COOKIE_NAME, 139 SessionMiddleware.encode_session(request.session.session_key, request.user), 140 max_age=max_age, 141 expires=expires, 142 domain=settings.SESSION_COOKIE_DOMAIN, 143 path=settings.SESSION_COOKIE_PATH, 144 secure=secure, 145 httponly=settings.SESSION_COOKIE_HTTPONLY or None, 146 samesite=same_site, 147 ) 148 return response 149 150 151class CsrfViewMiddleware(UpstreamCsrfViewMiddleware): 152 """Dynamically set secure depending if the upstream connection is TLS or not""" 153 154 def _set_csrf_cookie(self, request: HttpRequest, response: HttpResponse): 155 if settings.CSRF_USE_SESSIONS: 156 if request.session.get(CSRF_SESSION_KEY) != request.META["CSRF_COOKIE"]: 157 request.session[CSRF_SESSION_KEY] = request.META["CSRF_COOKIE"] 158 else: 159 secure = SessionMiddleware.is_secure(request) 160 response.set_cookie( 161 settings.CSRF_COOKIE_NAME, 162 request.META["CSRF_COOKIE"], 163 max_age=settings.CSRF_COOKIE_AGE, 164 domain=settings.CSRF_COOKIE_DOMAIN, 165 path=settings.CSRF_COOKIE_PATH, 166 secure=secure, 167 httponly=settings.CSRF_COOKIE_HTTPONLY, 168 samesite=settings.CSRF_COOKIE_SAMESITE, 169 ) 170 # Set the Vary header since content varies with the CSRF cookie. 171 patch_vary_headers(response, ("Cookie",)) 172 173 174class ClientIPMiddleware: 175 """Set a "known-good" client IP on the request, by default based off of x-forwarded-for 176 which is set by the go proxy, but also allowing the remote IP to be overridden by an outpost 177 for protocols like LDAP""" 178 179 get_response: Callable[[HttpRequest], HttpResponse] 180 outpost_remote_ip_header = "HTTP_X_AUTHENTIK_REMOTE_IP" 181 outpost_token_header = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec 182 default_ip = "255.255.255.255" 183 184 request_attr_client_ip = "client_ip" 185 request_attr_outpost_user = "outpost_user" 186 187 def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 188 self.get_response = get_response 189 self.logger = get_logger().bind() 190 191 def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: 192 """Attempt to get the client's IP by checking common HTTP Headers. 193 Returns none if no IP Could be found 194 195 No additional validation is done here as requests are expected to only arrive here 196 via the go proxy, which deals with validating these headers for us""" 197 headers = ( 198 "HTTP_X_FORWARDED_FOR", 199 "REMOTE_ADDR", 200 ) 201 try: 202 for _header in headers: 203 if _header in meta: 204 ips: list[str] = meta.get(_header).split(",") 205 # Ensure the IP parses as a valid IP 206 return str(ip_address(ips[0].strip())) 207 return self.default_ip 208 except ValueError as exc: 209 self.logger.debug("Invalid remote IP", exc=exc) 210 return self.default_ip 211 212 # FIXME: this should probably not be in `root` but rather in a middleware in `outposts` 213 # but for now it's fine 214 def _get_outpost_override_ip(self, request: HttpRequest) -> str | None: 215 """Get the actual remote IP when set by an outpost. Only 216 allowed when the request is authenticated, by an outpost internal service account""" 217 if ( 218 self.outpost_remote_ip_header not in request.META 219 or self.outpost_token_header not in request.META 220 ): 221 return None 222 delegated_ip = request.META[self.outpost_remote_ip_header] 223 token = ( 224 Token.objects.filter( 225 key=request.META.get(self.outpost_token_header), intent=TokenIntents.INTENT_API 226 ) 227 .select_related("user") 228 .first() 229 ) 230 if not token: 231 LOGGER.warning("Attempted remote-ip override without token", delegated_ip=delegated_ip) 232 return None 233 user: User = token.user 234 if user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: 235 LOGGER.warning( 236 "Remote-IP override: user doesn't have permission", 237 user=user, 238 delegated_ip=delegated_ip, 239 ) 240 return None 241 # Update sentry scope to include correct IP 242 sentry_user = Scope.get_isolation_scope()._user or {} 243 sentry_user["ip_address"] = delegated_ip 244 Scope.get_isolation_scope().set_user(sentry_user) 245 # Set the outpost service account on the request 246 setattr(request, self.request_attr_outpost_user, user) 247 try: 248 return str(ip_address(delegated_ip)) 249 except ValueError as exc: 250 self.logger.debug("Invalid remote IP from Outpost", exc=exc) 251 return None 252 253 def _get_client_ip(self, request: HttpRequest | None) -> str: 254 """Attempt to get the client's IP by checking common HTTP Headers. 255 Returns none if no IP Could be found""" 256 if not request: 257 return self.default_ip 258 override = self._get_outpost_override_ip(request) 259 if override: 260 return override 261 return self._get_client_ip_from_meta(request.META) 262 263 @staticmethod 264 def get_outpost_user(request: HttpRequest) -> User | None: 265 """Get outpost user that authenticated this request""" 266 return getattr(request, ClientIPMiddleware.request_attr_outpost_user, None) 267 268 @staticmethod 269 def get_client_ip(request: HttpRequest) -> str: 270 """Get correct client IP, including any overrides from outposts that 271 have the permission to do so""" 272 if request and not hasattr(request, ClientIPMiddleware.request_attr_client_ip): 273 ClientIPMiddleware(lambda request: request).set_ip(request) 274 return getattr( 275 request, ClientIPMiddleware.request_attr_client_ip, ClientIPMiddleware.default_ip 276 ) 277 278 def set_ip(self, request: HttpRequest): 279 """Set the IP""" 280 setattr(request, self.request_attr_client_ip, self._get_client_ip(request)) 281 282 def __call__(self, request: HttpRequest) -> HttpResponse: 283 self.set_ip(request) 284 return self.get_response(request) 285 286 287class ChannelsLoggingMiddleware: 288 """Logging middleware for channels""" 289 290 def __init__(self, inner): 291 self.inner = inner 292 293 async def __call__(self, scope, receive, send): 294 self.log(scope) 295 try: 296 return await self.inner(scope, receive, send) 297 except DenyConnection: 298 return await send({"type": "websocket.close"}) 299 except Exception as exc: 300 if settings.DEBUG or settings.TEST: 301 raise exc 302 LOGGER.warning("Exception in ASGI application", exc=exc) 303 return await send({"type": "websocket.close"}) 304 305 def log(self, scope: dict, **kwargs): 306 """Log request""" 307 headers = dict(scope.get("headers", {})) 308 LOGGER.info( 309 scope["path"], 310 scheme="ws", 311 remote=headers.get(b"x-forwarded-for", b"").decode(), 312 user_agent=headers.get(b"user-agent", b"").decode(), 313 **kwargs, 314 ) 315 316 317class LoggingMiddleware: 318 """Logger middleware""" 319 320 get_response: Callable[[HttpRequest], HttpResponse] 321 322 def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 323 self.get_response = get_response 324 headers = CONFIG.get("log.http_headers", []) 325 if isinstance(headers, str): 326 headers = headers.split(",") 327 self.headers_to_log = headers 328 329 def __call__(self, request: HttpRequest) -> HttpResponse: 330 start = perf_counter() 331 response = self.get_response(request) 332 status_code = response.status_code 333 kwargs = { 334 "request_id": getattr(request, "request_id", None), 335 } 336 kwargs.update(getattr(response, "ak_context", {})) 337 self.log(request, status_code, int((perf_counter() - start) * 1000), **kwargs) 338 return response 339 340 def log(self, request: HttpRequest, status_code: int, runtime: int, **kwargs): 341 """Log request""" 342 for header in self.headers_to_log: 343 header_value = request.headers.get(header) 344 if not header_value: 345 continue 346 kwargs[header.lower().replace("-", "_")] = header_value 347 LOGGER.info( 348 request.get_full_path(), 349 remote=ClientIPMiddleware.get_client_ip(request), 350 method=request.method, 351 scheme=request.scheme, 352 status=status_code, 353 runtime=runtime, 354 **kwargs, 355 )
33class SessionMiddleware(UpstreamSessionMiddleware): 34 """Dynamically set SameSite depending if the upstream connection is TLS or not""" 35 36 @staticmethod 37 def is_secure(request: HttpRequest) -> bool: 38 """Check if request is TLS'd or localhost""" 39 if request.is_secure(): 40 return True 41 host, _, _ = request.get_host().partition(":") 42 if host == "localhost" and settings.DEBUG: 43 # Since go does not consider localhost with http a secure origin 44 # we can't set the secure flag. 45 user_agent = request.META.get("HTTP_USER_AGENT", "") 46 if user_agent.startswith("goauthentik.io/outpost/") or ( 47 "safari" in user_agent.lower() and "chrome" not in user_agent.lower() 48 ): 49 return False 50 return True 51 return False 52 53 @staticmethod 54 def decode_session_key(key: str | None) -> str | None: 55 """Decode raw session cookie, and parse JWT""" 56 # We need to support the standard django format of just a session key 57 # for testing setups, where the session is directly set 58 session_key = key if settings.TEST else None 59 try: 60 session_payload = decode(key, SIGNING_HASH, algorithms=["HS256"]) 61 session_key = session_payload["sid"] 62 except KeyError, PyJWTError: 63 pass 64 return session_key 65 66 @staticmethod 67 def encode_session(session_key: str, user: User): 68 payload = { 69 "sid": session_key, 70 "iss": "authentik", 71 "sub": "anonymous", 72 "authenticated": user.is_authenticated, 73 "acr": ACR_AUTHENTIK_SESSION, 74 } 75 if user.is_authenticated: 76 payload["sub"] = user.uid 77 value = encode(payload=payload, key=SIGNING_HASH) 78 if settings.TEST: 79 value = session_key 80 return value 81 82 def process_request(self, request: HttpRequest): 83 raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 84 session_key = SessionMiddleware.decode_session_key(raw_session) 85 request.session = self.SessionStore( 86 session_key, 87 last_ip=ClientIPMiddleware.get_client_ip(request), 88 last_user_agent=request.META.get("HTTP_USER_AGENT", ""), 89 ) 90 91 def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: 92 """ 93 If request.session was modified, or if the configuration is to save the 94 session every time, save the changes and set a session cookie or delete 95 the session cookie if the session has been emptied. 96 """ 97 try: 98 accessed = request.session.accessed 99 modified = request.session.modified 100 empty = request.session.is_empty() 101 except AttributeError: 102 return response 103 # Set SameSite based on whether or not the request is secure 104 secure = SessionMiddleware.is_secure(request) 105 same_site = "None" if secure else "Lax" 106 # First check if we need to delete this cookie. 107 # The session should be deleted only if the session is entirely empty. 108 if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: 109 response.delete_cookie( 110 settings.SESSION_COOKIE_NAME, 111 path=settings.SESSION_COOKIE_PATH, 112 domain=settings.SESSION_COOKIE_DOMAIN, 113 samesite=same_site, 114 ) 115 patch_vary_headers(response, ("Cookie",)) 116 else: 117 if accessed: 118 patch_vary_headers(response, ("Cookie",)) 119 if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: 120 if request.session.get_expire_at_browser_close(): 121 max_age = None 122 expires = None 123 else: 124 max_age = request.session.get_expiry_age() 125 expires_time = time() + max_age 126 expires = http_date(expires_time) 127 # Save the session data and refresh the client cookie. 128 # Skip session save for 500 responses, refs #3881. 129 if response.status_code != HttpResponseServerError.status_code: 130 try: 131 request.session.save() 132 except UpdateError: 133 raise SessionInterrupted( 134 "The request's session was deleted before the " 135 "request completed. The user may have logged " 136 "out in a concurrent request, for example." 137 ) from None 138 response.set_cookie( 139 settings.SESSION_COOKIE_NAME, 140 SessionMiddleware.encode_session(request.session.session_key, request.user), 141 max_age=max_age, 142 expires=expires, 143 domain=settings.SESSION_COOKIE_DOMAIN, 144 path=settings.SESSION_COOKIE_PATH, 145 secure=secure, 146 httponly=settings.SESSION_COOKIE_HTTPONLY or None, 147 samesite=same_site, 148 ) 149 return response
Dynamically set SameSite depending if the upstream connection is TLS or not
36 @staticmethod 37 def is_secure(request: HttpRequest) -> bool: 38 """Check if request is TLS'd or localhost""" 39 if request.is_secure(): 40 return True 41 host, _, _ = request.get_host().partition(":") 42 if host == "localhost" and settings.DEBUG: 43 # Since go does not consider localhost with http a secure origin 44 # we can't set the secure flag. 45 user_agent = request.META.get("HTTP_USER_AGENT", "") 46 if user_agent.startswith("goauthentik.io/outpost/") or ( 47 "safari" in user_agent.lower() and "chrome" not in user_agent.lower() 48 ): 49 return False 50 return True 51 return False
Check if request is TLS'd or localhost
53 @staticmethod 54 def decode_session_key(key: str | None) -> str | None: 55 """Decode raw session cookie, and parse JWT""" 56 # We need to support the standard django format of just a session key 57 # for testing setups, where the session is directly set 58 session_key = key if settings.TEST else None 59 try: 60 session_payload = decode(key, SIGNING_HASH, algorithms=["HS256"]) 61 session_key = session_payload["sid"] 62 except KeyError, PyJWTError: 63 pass 64 return session_key
Decode raw session cookie, and parse JWT
66 @staticmethod 67 def encode_session(session_key: str, user: User): 68 payload = { 69 "sid": session_key, 70 "iss": "authentik", 71 "sub": "anonymous", 72 "authenticated": user.is_authenticated, 73 "acr": ACR_AUTHENTIK_SESSION, 74 } 75 if user.is_authenticated: 76 payload["sub"] = user.uid 77 value = encode(payload=payload, key=SIGNING_HASH) 78 if settings.TEST: 79 value = session_key 80 return value
82 def process_request(self, request: HttpRequest): 83 raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 84 session_key = SessionMiddleware.decode_session_key(raw_session) 85 request.session = self.SessionStore( 86 session_key, 87 last_ip=ClientIPMiddleware.get_client_ip(request), 88 last_user_agent=request.META.get("HTTP_USER_AGENT", ""), 89 )
91 def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: 92 """ 93 If request.session was modified, or if the configuration is to save the 94 session every time, save the changes and set a session cookie or delete 95 the session cookie if the session has been emptied. 96 """ 97 try: 98 accessed = request.session.accessed 99 modified = request.session.modified 100 empty = request.session.is_empty() 101 except AttributeError: 102 return response 103 # Set SameSite based on whether or not the request is secure 104 secure = SessionMiddleware.is_secure(request) 105 same_site = "None" if secure else "Lax" 106 # First check if we need to delete this cookie. 107 # The session should be deleted only if the session is entirely empty. 108 if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: 109 response.delete_cookie( 110 settings.SESSION_COOKIE_NAME, 111 path=settings.SESSION_COOKIE_PATH, 112 domain=settings.SESSION_COOKIE_DOMAIN, 113 samesite=same_site, 114 ) 115 patch_vary_headers(response, ("Cookie",)) 116 else: 117 if accessed: 118 patch_vary_headers(response, ("Cookie",)) 119 if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: 120 if request.session.get_expire_at_browser_close(): 121 max_age = None 122 expires = None 123 else: 124 max_age = request.session.get_expiry_age() 125 expires_time = time() + max_age 126 expires = http_date(expires_time) 127 # Save the session data and refresh the client cookie. 128 # Skip session save for 500 responses, refs #3881. 129 if response.status_code != HttpResponseServerError.status_code: 130 try: 131 request.session.save() 132 except UpdateError: 133 raise SessionInterrupted( 134 "The request's session was deleted before the " 135 "request completed. The user may have logged " 136 "out in a concurrent request, for example." 137 ) from None 138 response.set_cookie( 139 settings.SESSION_COOKIE_NAME, 140 SessionMiddleware.encode_session(request.session.session_key, request.user), 141 max_age=max_age, 142 expires=expires, 143 domain=settings.SESSION_COOKIE_DOMAIN, 144 path=settings.SESSION_COOKIE_PATH, 145 secure=secure, 146 httponly=settings.SESSION_COOKIE_HTTPONLY or None, 147 samesite=same_site, 148 ) 149 return response
If request.session was modified, or if the configuration is to save the session every time, save the changes and set a session cookie or delete the session cookie if the session has been emptied.
152class CsrfViewMiddleware(UpstreamCsrfViewMiddleware): 153 """Dynamically set secure depending if the upstream connection is TLS or not""" 154 155 def _set_csrf_cookie(self, request: HttpRequest, response: HttpResponse): 156 if settings.CSRF_USE_SESSIONS: 157 if request.session.get(CSRF_SESSION_KEY) != request.META["CSRF_COOKIE"]: 158 request.session[CSRF_SESSION_KEY] = request.META["CSRF_COOKIE"] 159 else: 160 secure = SessionMiddleware.is_secure(request) 161 response.set_cookie( 162 settings.CSRF_COOKIE_NAME, 163 request.META["CSRF_COOKIE"], 164 max_age=settings.CSRF_COOKIE_AGE, 165 domain=settings.CSRF_COOKIE_DOMAIN, 166 path=settings.CSRF_COOKIE_PATH, 167 secure=secure, 168 httponly=settings.CSRF_COOKIE_HTTPONLY, 169 samesite=settings.CSRF_COOKIE_SAMESITE, 170 ) 171 # Set the Vary header since content varies with the CSRF cookie. 172 patch_vary_headers(response, ("Cookie",))
Dynamically set secure depending if the upstream connection is TLS or not
175class ClientIPMiddleware: 176 """Set a "known-good" client IP on the request, by default based off of x-forwarded-for 177 which is set by the go proxy, but also allowing the remote IP to be overridden by an outpost 178 for protocols like LDAP""" 179 180 get_response: Callable[[HttpRequest], HttpResponse] 181 outpost_remote_ip_header = "HTTP_X_AUTHENTIK_REMOTE_IP" 182 outpost_token_header = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec 183 default_ip = "255.255.255.255" 184 185 request_attr_client_ip = "client_ip" 186 request_attr_outpost_user = "outpost_user" 187 188 def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 189 self.get_response = get_response 190 self.logger = get_logger().bind() 191 192 def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: 193 """Attempt to get the client's IP by checking common HTTP Headers. 194 Returns none if no IP Could be found 195 196 No additional validation is done here as requests are expected to only arrive here 197 via the go proxy, which deals with validating these headers for us""" 198 headers = ( 199 "HTTP_X_FORWARDED_FOR", 200 "REMOTE_ADDR", 201 ) 202 try: 203 for _header in headers: 204 if _header in meta: 205 ips: list[str] = meta.get(_header).split(",") 206 # Ensure the IP parses as a valid IP 207 return str(ip_address(ips[0].strip())) 208 return self.default_ip 209 except ValueError as exc: 210 self.logger.debug("Invalid remote IP", exc=exc) 211 return self.default_ip 212 213 # FIXME: this should probably not be in `root` but rather in a middleware in `outposts` 214 # but for now it's fine 215 def _get_outpost_override_ip(self, request: HttpRequest) -> str | None: 216 """Get the actual remote IP when set by an outpost. Only 217 allowed when the request is authenticated, by an outpost internal service account""" 218 if ( 219 self.outpost_remote_ip_header not in request.META 220 or self.outpost_token_header not in request.META 221 ): 222 return None 223 delegated_ip = request.META[self.outpost_remote_ip_header] 224 token = ( 225 Token.objects.filter( 226 key=request.META.get(self.outpost_token_header), intent=TokenIntents.INTENT_API 227 ) 228 .select_related("user") 229 .first() 230 ) 231 if not token: 232 LOGGER.warning("Attempted remote-ip override without token", delegated_ip=delegated_ip) 233 return None 234 user: User = token.user 235 if user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: 236 LOGGER.warning( 237 "Remote-IP override: user doesn't have permission", 238 user=user, 239 delegated_ip=delegated_ip, 240 ) 241 return None 242 # Update sentry scope to include correct IP 243 sentry_user = Scope.get_isolation_scope()._user or {} 244 sentry_user["ip_address"] = delegated_ip 245 Scope.get_isolation_scope().set_user(sentry_user) 246 # Set the outpost service account on the request 247 setattr(request, self.request_attr_outpost_user, user) 248 try: 249 return str(ip_address(delegated_ip)) 250 except ValueError as exc: 251 self.logger.debug("Invalid remote IP from Outpost", exc=exc) 252 return None 253 254 def _get_client_ip(self, request: HttpRequest | None) -> str: 255 """Attempt to get the client's IP by checking common HTTP Headers. 256 Returns none if no IP Could be found""" 257 if not request: 258 return self.default_ip 259 override = self._get_outpost_override_ip(request) 260 if override: 261 return override 262 return self._get_client_ip_from_meta(request.META) 263 264 @staticmethod 265 def get_outpost_user(request: HttpRequest) -> User | None: 266 """Get outpost user that authenticated this request""" 267 return getattr(request, ClientIPMiddleware.request_attr_outpost_user, None) 268 269 @staticmethod 270 def get_client_ip(request: HttpRequest) -> str: 271 """Get correct client IP, including any overrides from outposts that 272 have the permission to do so""" 273 if request and not hasattr(request, ClientIPMiddleware.request_attr_client_ip): 274 ClientIPMiddleware(lambda request: request).set_ip(request) 275 return getattr( 276 request, ClientIPMiddleware.request_attr_client_ip, ClientIPMiddleware.default_ip 277 ) 278 279 def set_ip(self, request: HttpRequest): 280 """Set the IP""" 281 setattr(request, self.request_attr_client_ip, self._get_client_ip(request)) 282 283 def __call__(self, request: HttpRequest) -> HttpResponse: 284 self.set_ip(request) 285 return self.get_response(request)
Set a "known-good" client IP on the request, by default based off of x-forwarded-for which is set by the go proxy, but also allowing the remote IP to be overridden by an outpost for protocols like LDAP
264 @staticmethod 265 def get_outpost_user(request: HttpRequest) -> User | None: 266 """Get outpost user that authenticated this request""" 267 return getattr(request, ClientIPMiddleware.request_attr_outpost_user, None)
Get outpost user that authenticated this request
269 @staticmethod 270 def get_client_ip(request: HttpRequest) -> str: 271 """Get correct client IP, including any overrides from outposts that 272 have the permission to do so""" 273 if request and not hasattr(request, ClientIPMiddleware.request_attr_client_ip): 274 ClientIPMiddleware(lambda request: request).set_ip(request) 275 return getattr( 276 request, ClientIPMiddleware.request_attr_client_ip, ClientIPMiddleware.default_ip 277 )
Get correct client IP, including any overrides from outposts that have the permission to do so
288class ChannelsLoggingMiddleware: 289 """Logging middleware for channels""" 290 291 def __init__(self, inner): 292 self.inner = inner 293 294 async def __call__(self, scope, receive, send): 295 self.log(scope) 296 try: 297 return await self.inner(scope, receive, send) 298 except DenyConnection: 299 return await send({"type": "websocket.close"}) 300 except Exception as exc: 301 if settings.DEBUG or settings.TEST: 302 raise exc 303 LOGGER.warning("Exception in ASGI application", exc=exc) 304 return await send({"type": "websocket.close"}) 305 306 def log(self, scope: dict, **kwargs): 307 """Log request""" 308 headers = dict(scope.get("headers", {})) 309 LOGGER.info( 310 scope["path"], 311 scheme="ws", 312 remote=headers.get(b"x-forwarded-for", b"").decode(), 313 user_agent=headers.get(b"user-agent", b"").decode(), 314 **kwargs, 315 )
Logging middleware for channels
306 def log(self, scope: dict, **kwargs): 307 """Log request""" 308 headers = dict(scope.get("headers", {})) 309 LOGGER.info( 310 scope["path"], 311 scheme="ws", 312 remote=headers.get(b"x-forwarded-for", b"").decode(), 313 user_agent=headers.get(b"user-agent", b"").decode(), 314 **kwargs, 315 )
Log request
318class LoggingMiddleware: 319 """Logger middleware""" 320 321 get_response: Callable[[HttpRequest], HttpResponse] 322 323 def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): 324 self.get_response = get_response 325 headers = CONFIG.get("log.http_headers", []) 326 if isinstance(headers, str): 327 headers = headers.split(",") 328 self.headers_to_log = headers 329 330 def __call__(self, request: HttpRequest) -> HttpResponse: 331 start = perf_counter() 332 response = self.get_response(request) 333 status_code = response.status_code 334 kwargs = { 335 "request_id": getattr(request, "request_id", None), 336 } 337 kwargs.update(getattr(response, "ak_context", {})) 338 self.log(request, status_code, int((perf_counter() - start) * 1000), **kwargs) 339 return response 340 341 def log(self, request: HttpRequest, status_code: int, runtime: int, **kwargs): 342 """Log request""" 343 for header in self.headers_to_log: 344 header_value = request.headers.get(header) 345 if not header_value: 346 continue 347 kwargs[header.lower().replace("-", "_")] = header_value 348 LOGGER.info( 349 request.get_full_path(), 350 remote=ClientIPMiddleware.get_client_ip(request), 351 method=request.method, 352 scheme=request.scheme, 353 status=status_code, 354 runtime=runtime, 355 **kwargs, 356 )
Logger middleware
341 def log(self, request: HttpRequest, status_code: int, runtime: int, **kwargs): 342 """Log request""" 343 for header in self.headers_to_log: 344 header_value = request.headers.get(header) 345 if not header_value: 346 continue 347 kwargs[header.lower().replace("-", "_")] = header_value 348 LOGGER.info( 349 request.get_full_path(), 350 remote=ClientIPMiddleware.get_client_ip(request), 351 method=request.method, 352 scheme=request.scheme, 353 status=status_code, 354 runtime=runtime, 355 **kwargs, 356 )
Log request