52 lines
1.6 KiB
Python
52 lines
1.6 KiB
Python
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
from functools import lru_cache
|
|
|
|
from django.conf import settings
|
|
from django.http import HttpRequest
|
|
|
|
|
|
@lru_cache(maxsize=32)
|
|
def _trusted_networks(
|
|
values: tuple[str, ...],
|
|
) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
|
return tuple(ipaddress.ip_network(value, strict=False) for value in values)
|
|
|
|
|
|
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
candidate = value.strip()
|
|
if not candidate:
|
|
return None
|
|
try:
|
|
return ipaddress.ip_address(candidate)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def client_ip(request: HttpRequest) -> str:
|
|
"""Return a proxy-aware client IP without trusting arbitrary forwarded headers."""
|
|
|
|
remote = _parse_ip(request.META.get("REMOTE_ADDR", ""))
|
|
if remote is None:
|
|
return "unknown"
|
|
|
|
networks = _trusted_networks(tuple(getattr(settings, "TRUSTED_PROXY_CIDRS", ())))
|
|
if not networks or not any(remote in network for network in networks):
|
|
return str(remote)
|
|
|
|
forwarded = [
|
|
parsed
|
|
for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
|
|
if (parsed := _parse_ip(item)) is not None
|
|
]
|
|
if not forwarded:
|
|
return str(remote)
|
|
|
|
# Walk from the nearest hop back to the client. The first address outside
|
|
# the explicitly trusted proxy ranges is the client address.
|
|
for candidate in reversed([*forwarded, remote]):
|
|
if not any(candidate in network for network in networks):
|
|
return str(candidate)
|
|
return str(forwarded[0])
|