Reported privately by David Gilman.
Harden (modern engine only; legacy is unchanged):
- A port longer than five significant digits is rejected, same as port
99999, instead of callingint()on an unbounded digit string. A client-controlled header of about 4.3 KB could otherwise makeget_client_ip()raiseValueError(Python'sintstring-digit limit). Zero-padded ports such as000080are still accepted. - IPv6 zone ids longer than 255 characters are rejected. RFC 4007 sets no maximum; 255 matches the longest common OS interface-name limit so a multi-kilobyte scope is not returned, logged, or stored. The legacy engine still accepts any length.
Best match (modern engine only; legacy is unchanged). Results can differ from 4.0.0 on well-formed input,
always toward a better address; use algorithm="legacy" for exact v3 results:
- Addresses are ranked public > private > link-local > loopback. Unspecified (
0.0.0.0,::), multicast, broadcast, and reserved addresses are never returned. Python reports multicast asis_global, so v3 could return224.0.0.1as 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.139now yields177.139.233.139. Withleftmost=Falsethe scan runs from the 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, setproxy_countorproxy_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_routeisTruefor any address resolved through a matching proxy config, including private clients; v3 reportedFalsefor them.- New exhaustive tests: every 1–3 hop chain over ten address kinds, in every leftmost / strict / proxy configuration, checked against an independent reference model; every header assignment and dict order; every spelling of a hop; never-worse-than-legacy across the whole matrix; seeded fuzzing.
Enhance (modern engine only; legacy is unchanged):
- Parse RFC 7239
Forwardedelements by theirfor=value, including quoted, bracketed IPv6 with a port. PreviouslyForwardednever produced an IP, so when it is present it can now resolve at its existing precedence slot, which is above the CDN headers. LikeX-Forwarded-For, a client can send it; behind a CDN, pass an explicitprecedencenaming 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 DoorX-Azure-ClientIP, DigitalOceanDO-Connecting-IP, Envoy/IstioX-Envoy-External-Address, plus the missingHTTP_X_CLIENT_IPand rawX-AppEngine-User-IPforms of headers already on the list. - Header names match case-insensitively (
-and_equivalent), so lowercase keys such as AWS Lambda's work. Exact keys still take priority. When several spellings fold to the same header, the dash spelling wins, whatever the dict order, so a client-sentx_forwarded_forcannot shadow the proxy'sx-forwarded-for. Dash spellings that disagree are treated as absent.
Harden (modern engine only):
- Reject malformed tokens instead of truncating them: unclosed brackets (
[::1), text after a bracket ([::1]junk), and non-numeric, empty, or out-of-range ports (1.2.3.4:abc,1.2.3.4:70000). Note v3 accepted1.2.3.4:abcas1.2.3.4. - Non-string header values (
None, bytes) are skipped instead of raisingAttributeError. proxy_listentries are stripped of whitespace. An empty entry now raisesValueError: it used to match every address and mark any spoofed chain as trusted.- Trusted-proxy matching is tighter. A complete IP entry is matched exactly: v3 prefix-matched it, so
"1.2.3.4"also trusted1.2.3.45, letting that host forge the client IP. Prefixes match on whole octets or groups ("10.1"matches10.1.x.x, not10.100.x.x). IPv6 entries are case- and zero-insensitive. An entry ending in:stays a prefix, so"2001:db8::"behaves as in 4.0.0. IPv4-mapped and NAT64 CIDR entries (::ffff:10.0.0.0/104) match the unwrapped IPv4 hops. - Configuration errors raise
ValueErrorat construction instead of misbehaving silently:proxy_listorprecedencepassed as a bare string (each character became an entry), a non-IPproxy_listentry such as"foo", or aproxy_countthat is a bool, a float, or a string.precedenceandproxy_listare copied, so later changes to the caller's lists have no effect. - A
metathat is not a mapping raisesTypeErrorwith a clear message. Non-string keys are skipped. - RFC 7239
Forwardedparsing is quote-aware: a,or;inside a quoted value no longer splits a hop, soext="x,8.8.8.8"cannot smuggle in a fake address or a fake proxy hop. - More address classes:
0.0.0.0/8is never returned. Deprecated site-localfec0::/10ranks as private, since Python reports it as global. RFC 8215 local-use NAT6464:ff9b:1::/48ranks as private, since Python reports it as reserved. - The unused
is_valid_iphelper was removed frompython_ipware.modern.parsers. It was never exported. - Test suite: 100% line and branch coverage of the modern engine.
CI:
- Bump
actions/upload-artifactto v7 andactions/download-artifactto v8, which run on Node 24.
Community (thank you!):
- Trusted proxies in
proxy_listcan now be CIDR networks, IPv4 or IPv6 (e.g.100.64.0.0/10), matched by real network membership; plain prefixes still work. Modern engine only. Requested by @griffi-gh (#26). - Added Fly.io's
Fly-Client-IPheader to the modern default precedence. Suggested by @mdalp (#23). - README now shows how to put a CDN header such as Cloudflare's first via
precedence, when all traffic comes through that CDN. Suggested by @iloveitaly (#25). - CI covers Python 3.13 and newer. Suggested by @iloveitaly (#24).
Enhance:
- Introduce a pluggable algorithm router:
IpWare(algorithm=...)with"auto"(default),"modern", and"legacy"."auto"resolves to"modern"— the enhanced engine and the forward-moving default."legacy"is an explicit escape hatch that runs the frozen v3 algorithm byte-for-byte.
- New
modernengine: hardened IPv6 / bracketed-port / IPv4-mapped parsing and an expanded default header precedence list (addsTrue-Client-IP,Fastly-Client-IP, App Engine, AzureX-Client-IP). - The frozen v3 algorithm is preserved unchanged under
python_ipware.legacy; the original v3 test suite passes against both the legacy and modern engines.
Modernize:
- Migrate packaging to PEP 621 with the Hatchling build backend; version is read from
__version__.py. - Drop end-of-life Python 3.7 / 3.8; requires Python 3.9+, tested on 3.9–3.14.
- Bump ruff config to the
lint.*table layout.
Note:
- No source change is required for existing users:
from python_ipware import IpWarecontinues to work and now defaults to the modern engine. On well-formed headers modern returns the same result as v3 (the full v3 suite and a legacy-vs-modern differential test pass). It differs only on malformed values:- quoted addresses such as
"1.2.3.4"are accepted (v3 ignored them); - a value with more than one port-like suffix, such as
1.2.3.4:80:90, is rejected (v3 took1.2.3.4). UseIpWare(algorithm="legacy")if you depend on the exact v3 handling of those inputs.
- quoted addresses such as
Fix:
- Release major version, as there is a possibility of api change causing minimal backward incompatibly
Enhance:
- AI assisted clean up
Enhance:
- Added
proxy_count=0as an option (@FraKraBa)
Enhance:
- Added
HTTP_CF_CONNECTING_IPto list of known ip headers (Adam M.)
Enhance:
- Added logger name
Issue:
- Remove
HTTP_VIAheader support (unreliable IP information) (@yourcelf)
Enhance:
- Include support for python 3.12
- Introduced breaking changes to avoid conflicts with the
django-ipwarepackage. - Renamed the imported module from
ipwaretopython_ipwarein thepython-ipwarepackage.- Old usage:
from ipware import IpWare - New usage:
from python_ipware import IpWare
- Old usage:
- Enhance: Readme updates
Features:
- Added
X-CLIENT-IPheader support - Adds PEP 561 Compatibility (@stumpylog)
- Streamline pyproject.toml & add trusted publishing (@stumpylog)
- Publish version 1.0.0
Features:
- Initial Release