net: strip the ephemeral source port from the forwarded client IP

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.
This commit is contained in:
cproudlock
2026-07-29 10:06:18 -04:00
parent 8dce622392
commit ced356882c
5 changed files with 95 additions and 14 deletions

View File

@@ -26,11 +26,10 @@ LOCKOUT_MINUTES = 15
def _login_ip():
"""Caller IP for rate limiting, honoring the first X-Forwarded-For hop."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr or 'unknown'
"""Caller IP for rate limiting, port stripped so the key is per-host, not
per-connection (ARR forwards clientip:port with an ephemeral port)."""
from shopdb.utils.clientip import client_ip
return client_ip(request) or 'unknown'
def _login_ratelimited():

View File

@@ -17,11 +17,10 @@ dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
def _request_ip():
"""Caller IP, honoring a single proxy hop via X-Forwarded-For."""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr
"""Caller IP for the IP fallback, port stripped so it matches a stored
(portless) DashboardDefault.ipaddress. ARR forwards clientip:port."""
from shopdb.utils.clientip import client_ip
return client_ip(request)
def _serialize(default):

View File

@@ -117,10 +117,9 @@ class AuditLog(db.Model):
useragent = None
if request_obj:
# Handle proxy forwarding
ipaddress = request_obj.headers.get('X-Forwarded-For', request_obj.remote_addr)
if ipaddress and ',' in ipaddress:
ipaddress = ipaddress.split(',')[0].strip()
# First-hop client IP, port stripped (ARR forwards clientip:port).
from shopdb.utils.clientip import client_ip
ipaddress = client_ip(request_obj)
useragent = request_obj.headers.get('User-Agent', '')[:255]
entry = cls(

39
shopdb/utils/clientip.py Normal file
View File

@@ -0,0 +1,39 @@
"""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)