54 lines
2.4 KiB
Python
54 lines
2.4 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.
|
|
|
|
X-Forwarded-For is NOT read here. A client can send that header itself, so
|
|
trusting it unconditionally hands any caller a free identity: the login rate
|
|
limit and the account lockout are keyed on this value, and the audit log
|
|
records it. Reading it directly also walked around TRUSTED_PROXY_COUNT, which
|
|
exists precisely to decide whether a proxy is really in front (see the ProxyFix
|
|
block in shopdb/__init__.py).
|
|
|
|
The trusted case is already handled upstream. When TRUSTED_PROXY_COUNT is set,
|
|
ProxyFix resolves X-Forwarded-For into remote_addr before the request reaches a
|
|
view, so remote_addr is the real client. When it is unset, remote_addr is the
|
|
peer that actually connected. Either way remote_addr is the trustworthy source,
|
|
which is why this module only strips the port from it.
|
|
|
|
A site behind IIS or nginx MUST set TRUSTED_PROXY_COUNT (1 for a single proxy),
|
|
or every request will key on the proxy's own address and the per-client rate
|
|
limit becomes one shared bucket.
|
|
|
|
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):
|
|
"""Client IP, port stripped, from the connecting peer.
|
|
|
|
Resolved from remote_addr, which ProxyFix has already rewritten from
|
|
X-Forwarded-For when TRUSTED_PROXY_COUNT says a proxy is really in front.
|
|
Returns None only when remote_addr is absent (callers that need a non-null
|
|
key coerce it).
|
|
"""
|
|
return strip_port(request_obj.remote_addr)
|