Files
shopdb-flask/shopdb/utils/clientip.py

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)