IIS ARR sets X-Forwarded-For to clientip:port, and the port changes every connection. Left in, the audit log showed IP:PORT, the dashboard IP fallback never matched a stored (portless) DashboardDefault.ipaddress, and login rate limiting keyed per-connection instead of per-host. Add an IPv6-safe clientip.client_ip / strip_port helper and use it in the audit log, the dashboard resolver, and the login rate-limit key.
40 lines
1.5 KiB
Python
40 lines
1.5 KiB
Python
"""Client IP extraction behind a reverse proxy.
|
|
|
|
Prod runs behind IIS ARR, which sets X-Forwarded-For to "clientip:port" (and
|
|
appends a hop per proxy). The trailing source port is ephemeral - it changes
|
|
every connection - so it must be stripped before the value is stored (audit),
|
|
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
|
|
it makes every request look like a different client.
|
|
|
|
Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a
|
|
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
|
|
"""
|
|
|
|
|
|
def strip_port(ipaddress):
|
|
"""Return ipaddress without a trailing :port. IPv6-safe. None/empty-safe."""
|
|
if not ipaddress:
|
|
return ipaddress
|
|
ipaddress = ipaddress.strip()
|
|
if ipaddress.startswith('['):
|
|
# [IPv6] or [IPv6]:port -> the address inside the brackets
|
|
return ipaddress[1:].split(']', 1)[0]
|
|
if ipaddress.count(':') == 1:
|
|
# exactly one colon = IPv4:port (a bare IPv6 has several colons)
|
|
return ipaddress.split(':', 1)[0]
|
|
return ipaddress
|
|
|
|
|
|
def client_ip(request_obj):
|
|
"""First-hop client IP behind one proxy, port stripped.
|
|
|
|
Honors the first X-Forwarded-For hop, else remote_addr. Returns None only
|
|
when neither is present (callers that need a non-null key coerce it).
|
|
"""
|
|
forwarded = request_obj.headers.get('X-Forwarded-For')
|
|
if forwarded:
|
|
raw = forwarded.split(',')[0].strip()
|
|
else:
|
|
raw = request_obj.remote_addr
|
|
return strip_port(raw)
|