diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a17357..54218b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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` diff --git a/README.md b/README.md index 74efd05..6ff2731 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/python_ipware/modern/parsers.py b/python_ipware/modern/parsers.py index 2f6fde1..c7f8b87 100644 --- a/python_ipware/modern/parsers.py +++ b/python_ipware/modern/parsers.py @@ -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 @@ -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 @@ -96,7 +104,11 @@ 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 @@ -104,8 +116,11 @@ def parse_ip(value: Optional[str]) -> Optional[IpAddressType]: 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 diff --git a/tests/tests_modern_matrix.py b/tests/tests_modern_matrix.py index b56518a..3b93951 100644 --- a/tests/tests_modern_matrix.py +++ b/tests/tests_modern_matrix.py @@ -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):