Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ always toward a better address; use `algorithm="legacy"` for exact v3 results:
`is_global`, so v3 could return `224.0.0.1` as the client.
- Without `proxy_count` / `proxy_list`, the first public hop of a chain wins, not only the first hop:
`10.0.0.1, 177.139.233.139` now yields `177.139.233.139`. With `leftmost=False` the scan runs from the
right. With proxy settings, the client position is fixed exactly as before.
right. With proxy settings, the client position is fixed exactly as before. Note that the public hop
may be an upstream proxy: if you must identify private (intranet / VPN) clients, set `proxy_count` or
`proxy_list`.
- NAT64 well-known-prefix addresses (`64:ff9b::a.b.c.d`, RFC 6052) are unwrapped to the embedded IPv4
client, like IPv4-mapped addresses. v3 returned the IPv6 form.
- `trusted_route` is `True` for any address resolved through a matching proxy config, including private
clients; v3 reported `False` for them.
- New exhaustive tests: every 1–3 hop chain over ten address kinds, in every leftmost / strict / proxy
Expand All @@ -17,7 +21,8 @@ always toward a better address; use `algorithm="legacy"` for exact v3 results:
Enhance (modern engine only; legacy is unchanged):
- Parse RFC 7239 `Forwarded` elements by their `for=` value, including quoted, bracketed IPv6 with a port.
Previously `Forwarded` never produced an IP, so when it is present it can now resolve at its existing
precedence slot. Obfuscated hops (`for=unknown`, `for=_hidden`) count as invalid tokens.
precedence slot, which is above the CDN headers. Like `X-Forwarded-For`, a client can send it; behind a
CDN, pass an explicit `precedence` naming that CDN's header. Obfuscated hops (`for=unknown`, `for=_hidden`) count as invalid tokens.
- New default headers, added only between the 4.0.0 entries and `REMOTE_ADDR`, so none outranks a header
that resolved requests before: Azure Front Door `X-Azure-ClientIP`, DigitalOcean `DO-Connecting-IP`,
Envoy/Istio `X-Envoy-External-Address`, plus the missing `HTTP_X_CLIENT_IP` and raw `X-AppEngine-User-IP`
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ Within one header, the client entry depends on your proxy settings:
- **`proxy_count` / `proxy_list` set:** the entry just before your trusted proxies, exactly as in v3.
- **Neither set:** the first public entry in the chain, not only the first entry. So
`10.0.0.1, 177.139.233.139` yields `177.139.233.139` (v3 returned `10.0.0.1`). With
`leftmost=False` the chain is scanned from the right.
`leftmost=False` the chain is scanned from the right. That public entry may be an upstream proxy
rather than the client. If you need to identify private clients (intranet, VPN), set
`proxy_count` or `proxy_list` so the client position is fixed.

Every default header can be sent by a client, and the modern engine now also reads `Forwarded`,
which sits above the CDN headers. Behind a CDN, pass an explicit `precedence` naming that CDN's
header (see below).

```mermaid
flowchart TD
Expand All @@ -140,8 +146,8 @@ flowchart TD
The legacy engine keeps v3's rules. A combination suite checks that the modern engine never returns a
worse address than legacy for the same input.

Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`) and IPv4-mapped IPv6 addresses
(`::ffff:1.2.3.4`) are returned as plain IPv4. RFC 7239 `Forwarded` elements are read by their
Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`). IPv4-mapped (`::ffff:1.2.3.4`) and NAT64
well-known-prefix (`64:ff9b::1.2.3.4`) addresses are returned as plain IPv4. RFC 7239 `Forwarded` elements are read by their
`for=` value (`for="[2001:db8::1]:4711";proto=https`). Malformed tokens such as `[::1`,
`[::1]junk`, or `1.2.3.4:abc` are rejected rather than truncated. Header names match
case-insensitively, so lowercase keys (AWS Lambda / API Gateway v2) work too.
Expand Down
23 changes: 19 additions & 4 deletions python_ipware/modern/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

IpAddressType = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]

# RFC 6052 well-known NAT64 prefix; the low 32 bits are the IPv4 client.
_NAT64_WKP = ipaddress.IPv6Network("64:ff9b::/96")

# How good an address is as a client IP; higher wins. REJECT is never returned.
TIER_REJECT = 0
TIER_LOOPBACK = 1
Expand All @@ -25,7 +28,12 @@ def ip_tier(ip: IpAddressType) -> int:
and the deprecated ``::a.b.c.d`` form as ``is_global``, and ``::1`` as
``is_reserved``, so those are resolved before ``is_global`` is trusted.
Unspecified, multicast, broadcast and reserved addresses can never be a
real client and are rejected outright.
real client and are rejected outright. NAT64 well-known-prefix addresses
never reach here: ``parse_ip`` unwraps them to IPv4 first.

Global/private classification comes from the running Python's
``ipaddress`` tables, which changed in 3.12 (e.g. 6to4 ``2002::/16`` is
global on 3.11 but private on 3.12+).
"""
if ip.is_unspecified or ip.is_multicast:
return TIER_REJECT
Expand Down Expand Up @@ -96,16 +104,23 @@ def clean_ip(value: Optional[str]) -> str:


def parse_ip(value: Optional[str]) -> Optional[IpAddressType]:
"""Return a validated ip_address object, or None. Unwraps IPv4-mapped IPv6."""
"""Return a validated ip_address object, or None.

Unwraps IPv4-mapped IPv6 (``::ffff:a.b.c.d``) and the RFC 6052 NAT64
well-known prefix (``64:ff9b::a.b.c.d``) to the embedded IPv4 client.
"""
cleaned = clean_ip(value)
if not cleaned:
return None
try:
ip = ipaddress.ip_address(cleaned)
except ValueError:
return None
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
return ip.ipv4_mapped
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped is not None:
return ip.ipv4_mapped
if ip in _NAT64_WKP:
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
return ip


Expand Down
17 changes: 17 additions & 0 deletions tests/tests_modern_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,23 @@ def test_rightmost_scans_from_the_right(self):
ip, _ = IpWare(leftmost=False).get_client_ip(meta)
self.assertEqual(str(ip), "177.139.233.139")

def test_nat64_well_known_prefix_unwrapped(self):
# v3 returned the IPv6 form; the embedded IPv4 is the real client.
cases = {
"64:ff9b::2d01:101": "45.1.1.1",
"[64:ff9b::b18b:e98b]:443": "177.139.233.139",
"64:ff9b::a00:1": "10.0.0.1",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": raw})
self.assertEqual(str(ip), expected)

def test_nat64_matches_proxy_as_ipv4(self):
ipw = IpWare(proxy_list=["198.84.193.157"])
meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 64:ff9b::c654:c19d"}
self.assertEqual(ipw.get_client_ip(meta, strict=True), (ipaddress.ip_address("177.139.233.139"), True))

def test_junk_addresses_never_returned(self):
for junk in ("0.0.0.0", "::", "224.0.0.1", "ff02::1", "255.255.255.255", "240.0.0.1", "::8.8.8.8"):
with self.subTest(junk=junk):
Expand Down
Loading