netx/netx_api/client_ip.py
oliver c4526e36d8 Polish HeroUI chrome, topology toolbar, and session client IP.
Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:10:57 +08:00

106 lines
3.1 KiB
Python

"""Resolve client IP behind reverse proxies (Vite / nginx / Caddy).
Only honors X-Forwarded-For / X-Real-IP / CF-Connecting-IP when the direct peer
is in NETX_TRUSTED_PROXY_IPS (default loopback). Otherwise the peer address is
used — prevents header spoofing from the public internet.
"""
from __future__ import annotations
import ipaddress
import re
from typing import Mapping
from starlette.requests import Request
from starlette.websockets import WebSocket
from .config import settings
_IP_RE = re.compile(
r"^(?:"
r"(?:\d{1,3}\.){3}\d{1,3}" # IPv4
r"|"
r"\[[0-9a-fA-F:]+\]" # [IPv6]
r"|"
r"[0-9a-fA-F:]+" # IPv6 bare
r")$"
)
def _trusted_peers() -> set[str]:
raw = str(getattr(settings, "trusted_proxy_ips", "") or "")
out: set[str] = set()
for part in raw.split(","):
p = part.strip()
if p:
out.add(p)
# Always treat classic loopback as trusted for local Vite/nginx.
out.update({"127.0.0.1", "::1", "localhost"})
return out
def _looks_like_ip(value: str) -> bool:
cand = str(value or "").strip()
if not cand or not _IP_RE.match(cand):
return False
if cand.startswith("[") and cand.endswith("]"):
cand = cand[1:-1]
try:
ipaddress.ip_address(cand)
return True
except ValueError:
return False
def _normalize_ip(value: str) -> str:
cand = str(value or "").strip()
if cand.startswith("[") and cand.endswith("]"):
cand = cand[1:-1]
return cand[:128]
def _header_map(headers: Mapping[str, str] | None) -> dict[str, str]:
if headers is None:
return {}
# Starlette Headers is case-insensitive; normalize keys for .get
return {str(k).lower(): str(v) for k, v in headers.items()}
def resolve_client_ip_from(peer: str, headers: Mapping[str, str] | None = None) -> str:
"""Pick the best client IP given the TCP peer and request headers."""
peer_ip = _normalize_ip(peer)
hdrs = _header_map(headers)
trusted = _trusted_peers()
peer_trusted = peer_ip in trusted or peer_ip.lower() in {x.lower() for x in trusted}
if not peer_trusted:
return peer_ip or ""
# Cloudflare (single value)
cf = (hdrs.get("cf-connecting-ip") or "").strip()
if _looks_like_ip(cf):
return _normalize_ip(cf)
# nginx often sets this to the original client
real = (hdrs.get("x-real-ip") or "").strip()
if _looks_like_ip(real):
return _normalize_ip(real)
# X-Forwarded-For: client, proxy1, proxy2 — take first public-looking hop
xff = hdrs.get("x-forwarded-for") or ""
for part in xff.split(","):
cand = part.strip()
if _looks_like_ip(cand):
return _normalize_ip(cand)
return peer_ip or ""
def resolve_client_ip(request: Request) -> str:
peer = str(request.client.host if request.client else "") or ""
return resolve_client_ip_from(peer, request.headers)
def resolve_websocket_client_ip(websocket: WebSocket) -> str:
peer = str(websocket.client.host if websocket.client else "") or ""
return resolve_client_ip_from(peer, websocket.headers)