python-ipware 3.0.0__py3-none-any.whl → 4.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
python_ipware/__init__.py CHANGED
@@ -1,2 +1,6 @@
1
- from .python_ipware import IpWare # noqa
2
- from .__version__ import __version__ # noqa
1
+ from .__version__ import __version__
2
+ from .legacy import LegacyIpWare
3
+ from .modern import ModernIpWare
4
+ from .router import IpWare
5
+
6
+ __all__ = ["IpWare", "LegacyIpWare", "ModernIpWare", "__version__"]
@@ -1 +1 @@
1
- __version__ = "3.0.0"
1
+ __version__ = "4.1.0"
@@ -1,33 +1,15 @@
1
- Metadata-Version: 2.1
2
- Name: python-ipware
3
- Version: 3.0.0
4
- Summary: A Python package to retrieve user's IP address
5
- Author-email: Val Neekman <info@neekware.com>
6
- License: MIT
7
- Project-URL: Documentation, https://github.com/un33k/python-ipware#readme
8
- Project-URL: Issues, https://github.com/un33k/python-ipware/issues
9
- Project-URL: Source, https://github.com/un33k/python-ipware
10
- Project-URL: Changelog, https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Natural Language :: English
14
- Classifier: License :: OSI Approved :: MIT License
15
- Classifier: Programming Language :: Python
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.7
18
- Classifier: Programming Language :: Python :: 3.8
19
- Classifier: Programming Language :: Python :: 3.9
20
- Classifier: Programming Language :: Python :: 3.10
21
- Classifier: Programming Language :: Python :: 3.11
22
- Classifier: Programming Language :: Python :: 3.12
23
- Requires-Python: >=3.7
24
- Description-Content-Type: text/markdown
25
- License-File: LICENSE
26
- Provides-Extra: dev
27
- Requires-Dist: ruff ; extra == 'dev'
28
- Requires-Dist: coveralls ~=3.3 ; extra == 'dev'
29
- Requires-Dist: coverage[toml] ; extra == 'dev'
30
- Requires-Dist: twine ; extra == 'dev'
1
+ # Legacy engine (v3) — how to use
2
+
3
+ > This is the original python-ipware 3.x guide, kept for reference. The legacy engine is frozen and only runs when requested explicitly with `IpWare(algorithm="legacy")`. For current usage see the [main README](../../README.md).
4
+
5
+ ```python
6
+ from python_ipware import IpWare
7
+
8
+ ipw = IpWare(algorithm="legacy")
9
+ ip, trusted_route = ipw.get_client_ip(request.META)
10
+ ```
11
+
12
+ ---
31
13
 
32
14
  # Python IPware (A Python Package)
33
15
 
@@ -0,0 +1,12 @@
1
+ """Frozen v3 algorithm.
2
+
3
+ This subpackage preserves the exact behavior of python-ipware 3.x. It is kept
4
+ byte-for-byte stable so that projects upgrading to 4.x can pin
5
+ ``algorithm="legacy"`` and get identical results to what they had before.
6
+
7
+ Do not "improve" this module. New behavior belongs in ``python_ipware.modern``.
8
+ """
9
+
10
+ from .engine import IpWare as LegacyIpWare
11
+
12
+ __all__ = ["LegacyIpWare"]
@@ -0,0 +1,5 @@
1
+ """The modern (v4) python-ipware algorithm."""
2
+
3
+ from .engine import ModernIpWare
4
+
5
+ __all__ = ["ModernIpWare"]
@@ -0,0 +1,52 @@
1
+ """Default header precedence for the modern engine.
2
+
3
+ Superset of the v3 list, adding widely-deployed CDN/edge headers. Headers are
4
+ scanned in order; the first one that yields a globally routable IP wins, with
5
+ private and then loopback addresses as fallbacks.
6
+
7
+ Ordering rule: existing entries never move. New headers are added only in the
8
+ block just above ``REMOTE_ADDR``, so a new header can outrank the raw socket
9
+ address but never a header that already resolved a request in an earlier
10
+ release.
11
+
12
+ Every default here can be forged by a client that reaches the app directly.
13
+ If all traffic arrives through one known edge, pass an explicit
14
+ ``precedence`` naming only that edge's header.
15
+ """
16
+
17
+ DEFAULT_PRECEDENCE: tuple[str, ...] = (
18
+ "X_FORWARDED_FOR",
19
+ "HTTP_X_FORWARDED_FOR",
20
+ "HTTP_CLIENT_IP",
21
+ "HTTP_X_REAL_IP",
22
+ "HTTP_X_FORWARDED",
23
+ "HTTP_X_CLUSTER_CLIENT_IP",
24
+ "HTTP_FORWARDED_FOR", # de facto variant; not defined by RFC 7239
25
+ "HTTP_FORWARDED", # RFC 7239 (for=...;proto=...), parsed per element
26
+ "HTTP_CF_CONNECTING_IP", # Cloudflare
27
+ "HTTP_TRUE_CLIENT_IP", # Cloudflare Enterprise / Akamai
28
+ "HTTP_FASTLY_CLIENT_IP", # Fastly / Firebase
29
+ "HTTP_FLY_CLIENT_IP", # Fly.io
30
+ "HTTP_X_APPENGINE_USER_IP", # Google App Engine
31
+ "X-CLIENT-IP", # Azure
32
+ "X-REAL-IP", # NGINX
33
+ "X-CLUSTER-CLIENT-IP", # Rackspace
34
+ "X_FORWARDED",
35
+ "FORWARDED_FOR",
36
+ "CF-CONNECTING-IP",
37
+ "TRUE-CLIENT-IP",
38
+ "FASTLY-CLIENT-IP",
39
+ "FLY-CLIENT-IP",
40
+ "FORWARDED",
41
+ "CLIENT-IP",
42
+ # --- added after 4.0.0: below every earlier entry, above REMOTE_ADDR ---
43
+ "HTTP_X_CLIENT_IP", # Azure X-Client-IP in Django/WSGI form
44
+ "X-APPENGINE-USER-IP", # Google App Engine, raw header form
45
+ "HTTP_X_AZURE_CLIENTIP", # Azure Front Door
46
+ "X-AZURE-CLIENTIP",
47
+ "HTTP_DO_CONNECTING_IP", # DigitalOcean App Platform
48
+ "DO-CONNECTING-IP",
49
+ "HTTP_X_ENVOY_EXTERNAL_ADDRESS", # Envoy / Istio
50
+ "X-ENVOY-EXTERNAL-ADDRESS",
51
+ "REMOTE_ADDR", # direct connection; always last
52
+ )
@@ -0,0 +1,256 @@
1
+ """The modern python-ipware engine.
2
+
3
+ Same inputs, outputs and proxy semantics as v3, with a better best-match:
4
+
5
+ * Superset header precedence (Forwarded parsing, more CDN / edge headers).
6
+ * Robust IPv6 / bracketed-port / IPv4-mapped / RFC 7239 parsing.
7
+ * Explicit ranking: global > private > link-local > loopback. Unspecified,
8
+ multicast, broadcast and reserved addresses are never returned.
9
+ * Without trusted-proxy config, the first *globally routable* hop of a chain
10
+ wins, not just the first hop, so ``10.0.0.1, 177.139.233.139`` yields the
11
+ public address. With ``proxy_count`` / ``proxy_list`` the client position is
12
+ fixed by the config, exactly as in v3.
13
+ * Same ``strict`` semantics for proxy_count / proxy_list validation.
14
+ * Trusted-proxy matching anchored to the end of the chain. Each ``proxy_list``
15
+ entry is a CIDR network (``"100.64.0.0/10"``, ``"fd7a:115c:a1e0::/48"``), a
16
+ complete IP matched exactly, or an IP prefix matched on octet / group
17
+ boundaries (``"10.1."``, ``"10.1"`` -> 10.1.x.x only).
18
+ * ``trusted_route`` is True whenever the returned IP came from a chain that
19
+ passed the configured proxy validation, whatever its tier.
20
+ * Misconfiguration fails loudly at construction instead of silently trusting
21
+ or never matching.
22
+ """
23
+
24
+ import ipaddress
25
+ from collections.abc import Mapping
26
+ from typing import Any, Optional, Union
27
+
28
+ from .defaults import DEFAULT_PRECEDENCE
29
+ from .parsers import (
30
+ TIER_GLOBAL,
31
+ TIER_REJECT,
32
+ IpAddressType,
33
+ IpNetworkType,
34
+ ip_tier,
35
+ split_proxy_chain,
36
+ unwrap_ipv4,
37
+ unwrap_ipv4_network,
38
+ )
39
+
40
+ OptionalIp = Optional[IpAddressType]
41
+ ProxyMatcher = Union[str, IpNetworkType]
42
+
43
+ _PREFIX_CHARS = frozenset("0123456789abcdef.:")
44
+
45
+
46
+ def _compile_proxy_matcher(pattern: str) -> ProxyMatcher:
47
+ """Compile one ``proxy_list`` entry.
48
+
49
+ * ``"10.0.0.0/8"`` -> network, matched by membership.
50
+ * ``"198.84.193.157"`` (a complete IP) -> exact /32 or /128 network, so it
51
+ can never match ``198.84.193.15x`` and IPv6 spelling (case, zeros) does
52
+ not matter.
53
+ * ``"198.84."`` / ``"10.1"`` / ``"2001:db8:"`` -> text prefix, matched on
54
+ octet / group boundaries only. An entry ending in ``:`` is always a
55
+ prefix, since ``"2001:db8::"`` historically meant "anything under it".
56
+ """
57
+ if "/" in pattern:
58
+ try:
59
+ # strict=False accepts host bits set, e.g. "10.0.0.5/24" -> 10.0.0.0/24.
60
+ return unwrap_ipv4_network(ipaddress.ip_network(pattern, strict=False))
61
+ except ValueError as exc:
62
+ msg = f"Invalid CIDR in proxy_list: {pattern!r}"
63
+ raise ValueError(msg) from exc
64
+ if not pattern.endswith(":"):
65
+ try:
66
+ return ipaddress.ip_network(unwrap_ipv4(ipaddress.ip_address(pattern)))
67
+ except ValueError:
68
+ pass
69
+ prefix = pattern.lower()
70
+ if not set(prefix) <= _PREFIX_CHARS:
71
+ msg = f"proxy_list entry is not an IP, CIDR or IP prefix: {pattern!r}"
72
+ raise ValueError(msg)
73
+ return prefix
74
+
75
+
76
+ def _proxy_matches(ip: IpAddressType, matcher: ProxyMatcher) -> bool:
77
+ if isinstance(matcher, str):
78
+ text = str(ip)
79
+ if not text.startswith(matcher):
80
+ return False
81
+ # Boundary check: "10.1" matches 10.1.x.x but not 10.100.x.x.
82
+ rest = text[len(matcher) :]
83
+ return not rest or matcher[-1] in ".:" or rest[0] in ".:%"
84
+ # Membership across IP versions is simply False, never an error.
85
+ return ip.version == matcher.version and ip in matcher
86
+
87
+
88
+ def _fold(key: str) -> str:
89
+ return key.upper().replace("-", "_")
90
+
91
+
92
+ def _build_folded(meta: Mapping[Any, Any]) -> dict[str, object]:
93
+ """Case- and dash-insensitive view of ``meta`` for lowercase-key adapters.
94
+
95
+ When several spellings fold to the same name, the result must not depend
96
+ on dict order. Real header names use dashes, while an underscore spelling
97
+ in a raw header dict can only come from the client, so dash spellings win.
98
+ If several dash spellings disagree, the header is treated as absent.
99
+ """
100
+ groups: dict[str, list[tuple[str, object]]] = {}
101
+ for key, value in meta.items():
102
+ if isinstance(key, str):
103
+ groups.setdefault(_fold(key), []).append((key, value))
104
+ folded: dict[str, object] = {}
105
+ for name, items in groups.items():
106
+ dashed = [v for k, v in items if "-" in k]
107
+ pool = dashed or [v for _, v in items]
108
+ distinct: list[object] = []
109
+ for value in pool:
110
+ if value not in distinct:
111
+ distinct.append(value)
112
+ if len(distinct) == 1:
113
+ folded[name] = distinct[0]
114
+ return folded
115
+
116
+
117
+ class ModernIpWare:
118
+ def __init__(
119
+ self,
120
+ precedence: Optional[tuple[str, ...]] = None,
121
+ leftmost: bool = True,
122
+ proxy_count: Optional[int] = None,
123
+ proxy_list: Optional[list[str]] = None,
124
+ ) -> None:
125
+ if proxy_count is not None and (
126
+ isinstance(proxy_count, bool) or not isinstance(proxy_count, int) or proxy_count < 0
127
+ ):
128
+ raise ValueError("proxy_count must be a non-negative integer")
129
+ # A bare string is iterable and would silently become one prefix per
130
+ # character ("10.0.0.1" -> "1", "0", ...), trusting almost anything.
131
+ if isinstance(proxy_list, str) or (
132
+ proxy_list is not None and not all(isinstance(p, str) for p in proxy_list)
133
+ ):
134
+ raise ValueError("proxy_list must be a list of strings.")
135
+ proxy_list = [p.strip() for p in proxy_list or []]
136
+ # An empty prefix matches every address, which would mark any spoofed
137
+ # chain as trusted. It is always a misconfiguration (e.g. a trailing
138
+ # comma in an env var), so fail loudly instead.
139
+ if any(not p for p in proxy_list):
140
+ raise ValueError("proxy_list entries must not be empty.")
141
+ # proxy_count and proxy_list may both be set and may differ (v3 API):
142
+ # the count is a hop-count requirement (minimum, or exact when strict)
143
+ # and the list pins the client position. See _best_from_chain.
144
+ if isinstance(precedence, str) or (
145
+ precedence is not None and not all(isinstance(h, str) for h in precedence)
146
+ ):
147
+ raise ValueError("precedence must be a sequence of header-name strings.")
148
+
149
+ # Copy, so later changes to the caller's objects cannot leak in.
150
+ self.precedence = tuple(precedence) if precedence else DEFAULT_PRECEDENCE
151
+ self.leftmost = leftmost
152
+ self.proxy_count = proxy_count
153
+ self.proxy_list = proxy_list
154
+ self._proxy_matchers = [_compile_proxy_matcher(p) for p in self.proxy_list]
155
+
156
+ # -- meta access --------------------------------------------------------
157
+
158
+ def _get_meta_values(self, meta: Optional[Mapping[Any, Any]]) -> list[str]:
159
+ if meta is None:
160
+ return []
161
+ if not isinstance(meta, Mapping):
162
+ msg = f"meta must be a mapping of header names to values, got {type(meta).__name__}"
163
+ raise TypeError(msg)
164
+ folded = _build_folded(meta)
165
+ values: list[str] = []
166
+ for key in self.precedence:
167
+ value = meta.get(key)
168
+ if value is None:
169
+ value = meta.get(key.replace("_", "-"))
170
+ # Exact keys win; the folded view only fills gaps, so lowercase
171
+ # keys (AWS Lambda / API Gateway v2, raw ASGI dicts) still match.
172
+ if value is None:
173
+ value = folded.get(_fold(key))
174
+ # Header values are text; anything else (None, bytes, lists from
175
+ # a misbehaving adapter) is ignored rather than crashing.
176
+ if isinstance(value, str) and value.strip():
177
+ values.append(value.strip())
178
+ return values
179
+
180
+ # -- validation ---------------------------------------------------------
181
+
182
+ def _proxy_count_valid(self, chain: list[IpAddressType], strict: bool) -> bool:
183
+ if self.proxy_count is None:
184
+ return True
185
+ proxies = len(chain) - 1
186
+ if strict:
187
+ return proxies == self.proxy_count
188
+ return proxies >= self.proxy_count
189
+
190
+ def _proxy_list_valid(self, chain: list[IpAddressType], strict: bool) -> bool:
191
+ if not self.proxy_list:
192
+ return True
193
+ count = len(self.proxy_list)
194
+ if strict and (len(chain) - 1) != count:
195
+ return False
196
+ if (len(chain) - 1) < count:
197
+ return False
198
+ # Compare the trailing proxies against the trusted entries in order.
199
+ return all(
200
+ _proxy_matches(ip, matcher)
201
+ for ip, matcher in zip(chain[-count:], self._proxy_matchers)
202
+ )
203
+
204
+ # -- selection ----------------------------------------------------------
205
+
206
+ def _best_from_chain(self, chain: list[IpAddressType]) -> tuple[OptionalIp, bool]:
207
+ # ``chain`` is already client-first (see get_client_ip) and non-empty.
208
+ if self.proxy_list:
209
+ return chain[-(len(self.proxy_list) + 1)], True
210
+ if self.proxy_count is not None:
211
+ return chain[-(self.proxy_count + 1)], True
212
+ # No trusted-proxy config, so no position in the chain is verified.
213
+ # Take the first globally routable hop; otherwise the best-ranked hop,
214
+ # earliest on ties. This never picks a worse address than v3's chain[0].
215
+ best: OptionalIp = None
216
+ best_tier = TIER_REJECT
217
+ for ip in chain:
218
+ tier = ip_tier(ip)
219
+ if tier == TIER_GLOBAL:
220
+ return ip, False
221
+ if tier > best_tier:
222
+ best, best_tier = ip, tier
223
+ return best, False
224
+
225
+ # -- public API ---------------------------------------------------------
226
+
227
+ def get_client_ip(self, meta: Optional[Mapping[Any, Any]], strict: bool = False) -> tuple[OptionalIp, bool]:
228
+ # Best non-global candidate so far. Strictly-greater comparison keeps
229
+ # the earliest header on ties, preserving header precedence.
230
+ fallback: OptionalIp = None
231
+ fallback_tier = TIER_REJECT
232
+ fallback_trusted = False
233
+
234
+ for raw in self._get_meta_values(meta):
235
+ chain = split_proxy_chain(raw, strict)
236
+ if not chain:
237
+ continue
238
+ # Put the chain in client-first order ONCE, before any validation, so
239
+ # the proxy checks and the client pick look at the same end.
240
+ if not self.leftmost:
241
+ chain.reverse()
242
+ if not self._proxy_count_valid(chain, strict):
243
+ continue
244
+ if not self._proxy_list_valid(chain, strict):
245
+ continue
246
+
247
+ ip, trusted = self._best_from_chain(chain)
248
+ if ip is None:
249
+ continue
250
+ tier = ip_tier(ip)
251
+ if tier == TIER_GLOBAL:
252
+ return ip, trusted
253
+ if tier > fallback_tier:
254
+ fallback, fallback_tier, fallback_trusted = ip, tier, trusted
255
+
256
+ return fallback, fallback_trusted
@@ -0,0 +1,210 @@
1
+ """Framework-agnostic IP parsing helpers for the modern engine.
2
+
3
+ Pure stdlib. Knows how to clean raw header tokens, strip ports/brackets,
4
+ read RFC 7239 ``Forwarded`` elements, validate IPv4/IPv6, and split proxy
5
+ chains.
6
+ """
7
+
8
+ import ipaddress
9
+ from typing import Optional, Union
10
+
11
+ IpAddressType = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
12
+ IpNetworkType = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
13
+
14
+ # RFC 6052 well-known NAT64 prefix; the low 32 bits are the IPv4 client.
15
+ _NAT64_WKP = ipaddress.IPv6Network("64:ff9b::/96")
16
+ # RFC 8215 local-use NAT64 prefix. The IPv4 position depends on the operator's
17
+ # chosen prefix length, so it is not unwrapped; it is ranked as private.
18
+ _NAT64_LOCAL = ipaddress.IPv6Network("64:ff9b:1::/48")
19
+ # IPv4-mapped IPv6 space. Hops in it are unwrapped to IPv4.
20
+ _IPV4_MAPPED = ipaddress.IPv6Network("::ffff:0:0/96")
21
+ # RFC 1122 "this network": never a valid source address.
22
+ _THIS_NETWORK = ipaddress.IPv4Network("0.0.0.0/8")
23
+
24
+ # How good an address is as a client IP; higher wins. REJECT is never returned.
25
+ TIER_REJECT = 0
26
+ TIER_LOOPBACK = 1
27
+ TIER_LINK_LOCAL = 2
28
+ TIER_PRIVATE = 3 # RFC 1918, ULA, CGNAT 100.64/10, documentation ranges, ...
29
+ TIER_GLOBAL = 4
30
+
31
+
32
+ def ip_tier(ip: IpAddressType) -> int:
33
+ """Rank ``ip`` as a client address candidate.
34
+
35
+ Check order matters: Python reports multicast (``224.0.0.1``, ``ff02::1``),
36
+ site-local ``fec0::/10`` and the deprecated ``::a.b.c.d`` form as
37
+ ``is_global``, and ``::1`` and ``64:ff9b:1::/48`` as ``is_reserved``, so
38
+ those are resolved before ``is_reserved`` / ``is_global`` are trusted.
39
+ Unspecified, ``0.0.0.0/8``, multicast, broadcast and reserved addresses can
40
+ never be a real client and are rejected outright. NAT64 well-known-prefix
41
+ addresses never reach here: ``parse_ip`` unwraps them to IPv4 first.
42
+
43
+ Global/private classification comes from the running Python's
44
+ ``ipaddress`` tables, which changed in 3.12 (e.g. 6to4 ``2002::/16`` is
45
+ global on 3.11 but private on 3.12+).
46
+ """
47
+ if ip.is_unspecified or ip.is_multicast:
48
+ return TIER_REJECT
49
+ if isinstance(ip, ipaddress.IPv4Address) and ip in _THIS_NETWORK:
50
+ return TIER_REJECT
51
+ if ip.is_loopback:
52
+ return TIER_LOOPBACK
53
+ if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_LOCAL:
54
+ return TIER_PRIVATE # Python calls it reserved (it sits in ::/8)
55
+ if ip.is_reserved: # 240.0.0.0/4 incl. 255.255.255.255; unallocated IPv6
56
+ return TIER_REJECT
57
+ if isinstance(ip, ipaddress.IPv6Address) and ip.is_site_local:
58
+ return TIER_PRIVATE # deprecated fec0::/10, which Python calls global
59
+ if ip.is_global:
60
+ return TIER_GLOBAL
61
+ if ip.is_link_local:
62
+ return TIER_LINK_LOCAL
63
+ return TIER_PRIVATE
64
+
65
+
66
+ def _is_port(value: str) -> bool:
67
+ # isascii() guards against Unicode digits such as "²" that isdigit() accepts.
68
+ return value.isascii() and value.isdigit() and int(value) <= 65535
69
+
70
+
71
+ def strip_port(value: str) -> str:
72
+ """Remove a trailing ``:port`` (IPv4) or ``[addr]:port`` (IPv6) suffix.
73
+
74
+ Returns ``""`` for a malformed token (unclosed bracket, text after the
75
+ bracket, or a non-numeric/out-of-range port) so it is rejected rather than
76
+ silently truncated into something that looks valid.
77
+ """
78
+ value = value.strip()
79
+ if not value:
80
+ return value
81
+
82
+ if value.startswith("["): # [addr] or [addr]:port
83
+ end = value.find("]")
84
+ if end == -1:
85
+ return ""
86
+ rest = value[end + 1 :]
87
+ if rest and not (rest.startswith(":") and _is_port(rest[1:])):
88
+ return ""
89
+ return value[1:end]
90
+
91
+ if value.count(":") == 1: # IPv4:port
92
+ host, _, port = value.partition(":")
93
+ return host if _is_port(port) else ""
94
+
95
+ return value # bare IPv6 or bare IPv4
96
+
97
+
98
+ def split_unquoted(value: str, sep: str) -> list[str]:
99
+ """Split ``value`` on ``sep``, ignoring separators inside double quotes.
100
+
101
+ RFC 7239 values may be quoted strings (``by="a,b"``), and a separator
102
+ inside one must not start a new hop or parameter. Backslash escapes inside
103
+ quotes are honoured. An unclosed quote swallows the rest of the value, so
104
+ that token fails to parse instead of leaking a fragment as a fake hop.
105
+ """
106
+ if '"' not in value:
107
+ return value.split(sep)
108
+ parts: list[str] = []
109
+ buf: list[str] = []
110
+ quoted = escaped = False
111
+ for ch in value:
112
+ if escaped:
113
+ escaped = False
114
+ elif quoted and ch == "\\":
115
+ escaped = True
116
+ elif ch == '"':
117
+ quoted = not quoted
118
+ elif ch == sep and not quoted:
119
+ parts.append("".join(buf))
120
+ buf = []
121
+ continue
122
+ buf.append(ch)
123
+ parts.append("".join(buf))
124
+ return parts
125
+
126
+
127
+ def forwarded_for(element: str) -> str:
128
+ """Return the ``for=`` value of one RFC 7239 ``Forwarded`` element.
129
+
130
+ ``for=192.0.2.60;proto=http;by=203.0.113.43`` -> ``192.0.2.60``. Parameter
131
+ names are case-insensitive, and ``;`` inside quoted values is not a
132
+ separator. Returns ``""`` when the element has no ``for`` parameter, so the
133
+ hop counts as invalid instead of being guessed at.
134
+ """
135
+ for pair in split_unquoted(element, ";"):
136
+ key, sep, val = pair.partition("=")
137
+ if sep and key.strip().lower() == "for":
138
+ return val.strip()
139
+ return ""
140
+
141
+
142
+ def clean_ip(value: Optional[str]) -> str:
143
+ """Normalize a raw candidate token into a bare IP string."""
144
+ if not value:
145
+ return ""
146
+ value = value.strip().strip('"').strip("'")
147
+ value = strip_port(value)
148
+ return value.strip()
149
+
150
+
151
+ def parse_ip(value: Optional[str]) -> Optional[IpAddressType]:
152
+ """Return a validated ip_address object, or None.
153
+
154
+ Unwraps IPv4-mapped IPv6 (``::ffff:a.b.c.d``) and the RFC 6052 NAT64
155
+ well-known prefix (``64:ff9b::a.b.c.d``) to the embedded IPv4 client.
156
+ """
157
+ cleaned = clean_ip(value)
158
+ if not cleaned:
159
+ return None
160
+ try:
161
+ return unwrap_ipv4(ipaddress.ip_address(cleaned))
162
+ except ValueError:
163
+ return None
164
+
165
+
166
+ def unwrap_ipv4(ip: IpAddressType) -> IpAddressType:
167
+ """Return the IPv4 address embedded in an IPv4-mapped or NAT64-WKP address."""
168
+ if isinstance(ip, ipaddress.IPv6Address):
169
+ if ip.ipv4_mapped is not None:
170
+ return ip.ipv4_mapped
171
+ if ip in _NAT64_WKP:
172
+ return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
173
+ return ip
174
+
175
+
176
+ def unwrap_ipv4_network(net: IpNetworkType) -> IpNetworkType:
177
+ """IPv4 network embedded in a /96-or-longer IPv4-mapped or NAT64-WKP network.
178
+
179
+ Hops in those ranges are unwrapped to IPv4 before matching, so a trusted
180
+ proxy written as ``::ffff:10.0.0.0/104`` must match the IPv4 hop too.
181
+ """
182
+ if isinstance(net, ipaddress.IPv6Network) and net.prefixlen >= 96:
183
+ for embedding in (_IPV4_MAPPED, _NAT64_WKP):
184
+ if net.subnet_of(embedding):
185
+ return ipaddress.IPv4Network((int(net.network_address) & 0xFFFFFFFF, net.prefixlen - 96))
186
+ return net
187
+
188
+
189
+ def split_proxy_chain(raw: Optional[str], strict: bool = False) -> Optional[list[IpAddressType]]:
190
+ """Split a comma-separated proxy chain into ordered ``ip_address`` objects.
191
+
192
+ Order is preserved left-to-right as it appears in the header. Tokens in
193
+ RFC 7239 form (``for=...;proto=...``) are reduced to their ``for`` value;
194
+ obfuscated identifiers such as ``for=unknown`` or ``for=_hidden`` are not
195
+ IPs and count as invalid. In strict mode, any invalid or empty token makes
196
+ the whole chain invalid (returns None), since a malformed header should not
197
+ be trusted. Otherwise invalid and empty tokens are skipped.
198
+ """
199
+ if not raw:
200
+ return []
201
+ result: list[IpAddressType] = []
202
+ for token in split_unquoted(raw, ","):
203
+ # No plain IP token contains "=", only RFC 7239 Forwarded elements do.
204
+ candidate = forwarded_for(token) if "=" in token else token
205
+ ip = parse_ip(candidate)
206
+ if ip is not None:
207
+ result.append(ip)
208
+ elif strict:
209
+ return None
210
+ return result
@@ -0,0 +1,83 @@
1
+ """Public ``IpWare`` facade with an algorithm router.
2
+
3
+ python-ipware 4.x ships two engines:
4
+
5
+ * ``legacy`` -> the frozen, byte-compatible v3 algorithm.
6
+ * ``modern`` -> the enhanced v4 algorithm (more headers, hardened parsing).
7
+
8
+ The ``algorithm`` selector chooses between them. ``"auto"`` (the default) is a
9
+ clean alias for ``"modern"`` — the enhanced engine is where development moves
10
+ forward. It passes the full v3 suite, and a differential test checks it never
11
+ returns a worse address than v3. It picks better where v3 did not: a public
12
+ hop behind a private first hop, no multicast / unspecified results, and
13
+ ``trusted_route`` for private clients behind trusted proxies (see CHANGELOG).
14
+ ``legacy`` remains available as an explicit escape hatch for projects that
15
+ need byte-for-byte v3 behavior. There is no silent runtime fallback, so
16
+ behavior stays predictable.
17
+
18
+ from python_ipware import IpWare
19
+
20
+ IpWare() # auto -> modern (the forward-moving default)
21
+ IpWare(algorithm="modern") # explicit modern
22
+ IpWare(algorithm="legacy") # frozen v3 behavior (escape hatch)
23
+ """
24
+
25
+ from typing import Literal, Optional
26
+
27
+ from .legacy import LegacyIpWare
28
+ from .modern import ModernIpWare
29
+
30
+ Algorithm = Literal["auto", "modern", "legacy"]
31
+ _VALID = ("auto", "modern", "legacy")
32
+
33
+
34
+ class IpWare:
35
+ """Best-effort client IP resolver with a pluggable algorithm."""
36
+
37
+ def __init__(
38
+ self,
39
+ precedence: Optional[tuple[str, ...]] = None,
40
+ leftmost: bool = True,
41
+ proxy_count: Optional[int] = None,
42
+ proxy_list: Optional[list[str]] = None,
43
+ algorithm: Algorithm = "auto",
44
+ ) -> None:
45
+ if algorithm not in _VALID:
46
+ msg = f"algorithm must be one of {_VALID}, got {algorithm!r}"
47
+ raise ValueError(msg)
48
+
49
+ self.algorithm: Algorithm = algorithm
50
+ # "auto" resolves to "modern": the enhanced engine is the forward-moving
51
+ # default. "legacy" stays available as an explicit escape hatch.
52
+ resolved = "modern" if algorithm == "auto" else algorithm
53
+ self.resolved_algorithm = resolved
54
+
55
+ if resolved == "legacy":
56
+ self._impl = LegacyIpWare(
57
+ precedence=precedence,
58
+ leftmost=leftmost,
59
+ proxy_count=proxy_count,
60
+ proxy_list=proxy_list,
61
+ )
62
+ else:
63
+ self._impl = ModernIpWare(
64
+ precedence=precedence,
65
+ leftmost=leftmost,
66
+ proxy_count=proxy_count,
67
+ proxy_list=proxy_list,
68
+ )
69
+
70
+ @property
71
+ def engine(self) -> "LegacyIpWare | ModernIpWare":
72
+ """The concrete engine instance selected by ``algorithm`` (read-only)."""
73
+ return self._impl
74
+
75
+ def get_client_ip(self, meta, strict: bool = False):
76
+ """Delegate to the resolved engine. Returns ``(ip, trusted_route)``."""
77
+ return self._impl.get_client_ip(meta, strict)
78
+
79
+ def __repr__(self) -> str:
80
+ return (
81
+ f"IpWare(algorithm={self.algorithm!r} -> "
82
+ f"{self.resolved_algorithm!r})"
83
+ )
@@ -0,0 +1,372 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-ipware
3
+ Version: 4.1.0
4
+ Summary: A Python package to retrieve a client's real IP address.
5
+ Project-URL: Homepage, https://github.com/un33k/python-ipware
6
+ Project-URL: Documentation, https://github.com/un33k/python-ipware#readme
7
+ Project-URL: Repository, https://github.com/un33k/python-ipware
8
+ Project-URL: Issues, https://github.com/un33k/python-ipware/issues
9
+ Project-URL: Changelog, https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md
10
+ Author-email: Val Neekman <info@neekware.com>
11
+ Maintainer-email: Val Neekman <info@neekware.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: client ip,ip,ipware,proxy,python,real ip,remote addr,x-forwarded-for
15
+ Classifier: Development Status :: 5 - Production/Stable
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Natural Language :: English
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: Topic :: Internet :: WWW/HTTP
30
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
31
+ Classifier: Typing :: Typed
32
+ Requires-Python: >=3.9
33
+ Provides-Extra: dev
34
+ Requires-Dist: build; extra == 'dev'
35
+ Requires-Dist: coverage[toml]; extra == 'dev'
36
+ Requires-Dist: ruff==0.16.8; extra == 'dev'
37
+ Requires-Dist: twine; extra == 'dev'
38
+ Provides-Extra: test
39
+ Requires-Dist: coverage[toml]; extra == 'test'
40
+ Requires-Dist: ruff==0.16.8; extra == 'test'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # Python IPware
44
+
45
+ Best-effort client IP detection for Python server applications — Django, Flask, or any WSGI/ASGI framework.
46
+
47
+ [![status-image]][status-link]
48
+ [![version-image]][version-link]
49
+ [![coverage-image]][coverage-link]
50
+ [![maintained-image]][maintained-link]
51
+
52
+ ## Quickstart
53
+
54
+ ```sh
55
+ python -m pip install --upgrade python-ipware
56
+ ```
57
+
58
+ ```python
59
+ from python_ipware import IpWare
60
+
61
+ ipw = IpWare()
62
+
63
+ # Django: request.META | Flask: request.environ
64
+ ip, trusted_route = ipw.get_client_ip(request.META)
65
+
66
+ if ip:
67
+ # ip is an ipaddress.IPv4Address or IPv6Address
68
+ ip.is_global # publicly routable
69
+ ip.is_private # private network
70
+ ip.is_loopback # 127.0.0.1 / ::1
71
+
72
+ if trusted_route:
73
+ # the request came through your configured proxies (proxy_count / proxy_list)
74
+ ...
75
+ ```
76
+
77
+ Python 3.9+ is supported (tested on 3.9 – 3.14), with no upper version cap. No runtime dependencies.
78
+ On a newer Python that isn't in the test matrix yet? It should just work — if it doesn't,
79
+ [open an issue](https://github.com/un33k/python-ipware/issues) and we'll fix it.
80
+
81
+ > **Legacy:** the frozen 3.x algorithm is still available with `IpWare(algorithm="legacy")`.
82
+ > See the [legacy guide](https://github.com/un33k/python-ipware/blob/main/python_ipware/legacy/README.md).
83
+
84
+ ## What it's used for
85
+
86
+ ```mermaid
87
+ flowchart LR
88
+ R["Incoming request"] --> I["IpWare().get_client_ip(...)"]
89
+ I --> RL["Rate limiting and throttling"]
90
+ I --> GEO["Geo-location and localization"]
91
+ I --> LOG["Audit and access logs"]
92
+ I --> FR["Abuse and fraud signals<br/>(check trusted_route)"]
93
+ I --> AUTH["Login anomaly checks<br/>(check trusted_route)"]
94
+ ```
95
+
96
+ ## Security notice
97
+
98
+ > **Found a security issue?** Please email **info@neekware.com** privately — do not open a public
99
+ > issue or pull request. See [SECURITY.md](https://github.com/un33k/python-ipware/blob/main/SECURITY.md).
100
+
101
+ There is no perfect defense against IP address spoofing. Headers such as `X-Forwarded-For` are set by
102
+ clients and proxies, and can be forged. If you use `python-ipware` for authentication, rate limiting, or
103
+ anti-fraud, configure `proxy_count` and/or `proxy_list` for your network topology and treat it as one
104
+ layer alongside your firewall — never as the only defense.
105
+
106
+ ```mermaid
107
+ sequenceDiagram
108
+ participant A as Attacker (real IP 8.8.8.8)
109
+ participant P as Your proxy chain
110
+ participant App as Your app
111
+ A->>P: X-Forwarded-For: 1.2.3.4 (forged)
112
+ P->>App: X-Forwarded-For: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1
113
+ Note over App: IpWare() trusts the left-most entry and returns 1.2.3.4 (spoofed)
114
+ Note over App: IpWare(proxy_count=2) counts from the right and returns 8.8.8.8
115
+ Note over App: Adding strict=True rejects the tampered header entirely
116
+ ```
117
+
118
+ ## API
119
+
120
+ ```python
121
+ IpWare(
122
+ precedence=None, # tuple of header keys to check, in order
123
+ leftmost=True, # client is the left-most IP in the chain
124
+ proxy_count=None, # expected number of proxies in front of your server
125
+ proxy_list=None, # trusted proxies: CIDR networks or IP prefixes
126
+ )
127
+
128
+ ip, trusted_route = ipw.get_client_ip(meta, strict=False)
129
+ ```
130
+
131
+ | Parameter | Description |
132
+ | --- | --- |
133
+ | `precedence` | Header keys to search, top to bottom. Defaults to the list below. |
134
+ | `leftmost` | `True` (default) follows the de-facto `client, proxy1, proxy2` order. Use `False` only for networks that put the client right-most. |
135
+ | `proxy_count` | Number of proxies expected after the client. `0` is valid; `None` disables the check. |
136
+ | `proxy_list` | Trusted proxies nearest your server, one entry per hop. Each entry is a CIDR network (`"100.64.0.0/10"`, `"fd7a:115c:a1e0::/48"`), a complete IP matched exactly (`"198.84.193.157"`), or an IP prefix matched on whole octets (`"10.1."`). See [Trusted proxies](#trusted-proxies). |
137
+ | `strict` | `False`: at least `proxy_count` / `proxy_list` proxies. `True`: exactly that many — extra or invalid entries reject the header. |
138
+
139
+ | Output | Description |
140
+ | --- | --- |
141
+ | `ip` | `IPv4Address`, `IPv6Address`, or `None` |
142
+ | `trusted_route` | `True` when `proxy_count` or `proxy_list` was configured and matched, for any returned IP (v3 only set it for public IPs) |
143
+
144
+ ### Selection rules
145
+
146
+ Headers are checked in precedence order. Every address is ranked:
147
+
148
+ | Rank | Addresses |
149
+ | --- | --- |
150
+ | 1. public | globally routable |
151
+ | 2. private | RFC 1918, IPv6 ULA, CGNAT `100.64.0.0/10`, documentation ranges |
152
+ | 3. link-local | `169.254.0.0/16`, `fe80::/10` |
153
+ | 4. loopback | `127.0.0.0/8`, `::1` |
154
+ | never returned | `0.0.0.0`, `::`, multicast, broadcast, reserved |
155
+
156
+ The first **public** IP wins. If none is found, the best-ranked IP wins, and the earlier header
157
+ wins a tie.
158
+
159
+ Within one header, the client entry depends on your proxy settings:
160
+
161
+ - **`proxy_count` / `proxy_list` set:** the entry just before your trusted proxies, exactly as in v3.
162
+ - **Neither set:** the first public entry in the chain, not only the first entry. So
163
+ `10.0.0.1, 177.139.233.139` yields `177.139.233.139` (v3 returned `10.0.0.1`). With
164
+ `leftmost=False` the chain is scanned from the right. That public entry may be an upstream proxy
165
+ rather than the client. If you need to identify private clients (intranet, VPN), set
166
+ `proxy_count` or `proxy_list` so the client position is fixed.
167
+
168
+ Every default header can be sent by a client, and the modern engine now also reads `Forwarded`,
169
+ which sits above the CDN headers. Behind a CDN, pass an explicit `precedence` naming that CDN's
170
+ header (see below).
171
+
172
+ ```mermaid
173
+ flowchart TD
174
+ A["Request headers (...)"] --> B["Take the next header in precedence order"]
175
+ B --> C{"Header present?"}
176
+ C -->|no| B
177
+ C -->|yes| D["Split the chain: client, proxy1, proxy2"]
178
+ D --> E{"Matches proxy_count and proxy_list?"}
179
+ E -->|no| B
180
+ E -->|yes| F["Pick the client entry"]
181
+ F --> G{"Public IP?"}
182
+ G -->|yes| H["Return (ip, trusted_route)"]
183
+ G -->|no| I["Keep if it outranks the current fallback"]
184
+ I --> B
185
+ B -->|no headers left| J["Return the best fallback, else None"]
186
+ ```
187
+
188
+ The legacy engine keeps v3's rules. A combination suite checks that the modern engine never returns a
189
+ worse address than legacy for the same input.
190
+
191
+ Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`). IPv4-mapped (`::ffff:1.2.3.4`) and NAT64
192
+ well-known-prefix (`64:ff9b::1.2.3.4`) addresses are returned as plain IPv4. RFC 7239 `Forwarded` elements are read by their
193
+ `for=` value (`for="[2001:db8::1]:4711";proto=https`). Malformed tokens such as `[::1`,
194
+ `[::1]junk`, or `1.2.3.4:abc` are rejected rather than truncated. Header names match
195
+ case-insensitively, so lowercase keys (AWS Lambda / API Gateway v2) work too.
196
+
197
+ ## Default header precedence
198
+
199
+ ```python
200
+ (
201
+ "X_FORWARDED_FOR", # load balancers / proxies (AWS ELB, etc.)
202
+ "HTTP_X_FORWARDED_FOR",
203
+ "HTTP_CLIENT_IP", # Amazon EC2, Heroku
204
+ "HTTP_X_REAL_IP",
205
+ "HTTP_X_FORWARDED", # Squid
206
+ "HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB, Riverbed Stingray
207
+ "HTTP_FORWARDED_FOR", # de facto variant
208
+ "HTTP_FORWARDED", # RFC 7239
209
+ "HTTP_CF_CONNECTING_IP", # Cloudflare
210
+ "HTTP_TRUE_CLIENT_IP", # Cloudflare Enterprise, Akamai
211
+ "HTTP_FASTLY_CLIENT_IP", # Fastly, Firebase
212
+ "HTTP_FLY_CLIENT_IP", # Fly.io
213
+ "HTTP_X_APPENGINE_USER_IP", # Google App Engine
214
+ "X-CLIENT-IP", # Microsoft Azure
215
+ "X-REAL-IP", # NGINX
216
+ "X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
217
+ "X_FORWARDED",
218
+ "FORWARDED_FOR",
219
+ "CF-CONNECTING-IP",
220
+ "TRUE-CLIENT-IP",
221
+ "FASTLY-CLIENT-IP",
222
+ "FLY-CLIENT-IP",
223
+ "FORWARDED",
224
+ "CLIENT-IP",
225
+ # added after 4.0.0 — always below every earlier entry, above REMOTE_ADDR
226
+ "HTTP_X_CLIENT_IP", # Microsoft Azure (Django/WSGI form)
227
+ "X-APPENGINE-USER-IP", # Google App Engine (raw form)
228
+ "HTTP_X_AZURE_CLIENTIP", # Azure Front Door
229
+ "X-AZURE-CLIENTIP",
230
+ "HTTP_DO_CONNECTING_IP", # DigitalOcean App Platform
231
+ "DO-CONNECTING-IP",
232
+ "HTTP_X_ENVOY_EXTERNAL_ADDRESS", # Envoy / Istio
233
+ "X-ENVOY-EXTERNAL-ADDRESS",
234
+ "REMOTE_ADDR", # direct connection
235
+ )
236
+ ```
237
+
238
+ Headers released earlier never move. New ones are added only just above `REMOTE_ADDR`, so an
239
+ upgrade can never let a new header outrank one that already resolved your requests.
240
+
241
+ Narrow it to what your infrastructure actually sets:
242
+
243
+ ```python
244
+ ipw = IpWare(precedence=("HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))
245
+ ```
246
+
247
+ If **all** your traffic comes through a CDN, put its header first. Only do this when the app is not
248
+ reachable directly, because clients can send these headers themselves:
249
+
250
+ ```python
251
+ # Behind Cloudflare only
252
+ ipw = IpWare(precedence=("HTTP_CF_CONNECTING_IP", "HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))
253
+ ```
254
+
255
+ ## Trusted proxies
256
+
257
+ If your server sits behind known proxies, pass their IPs or prefixes:
258
+
259
+ Each entry can be (modern engine):
260
+
261
+ - **a complete IP**, matched exactly: `"198.84.193.157"` never matches `198.84.193.15x`, and IPv6
262
+ spelling (case, leading zeros) does not matter;
263
+ - **a CIDR network** (IPv4 or IPv6), matched by membership. This is the recommended form for IPv6;
264
+ - **an IP prefix**, matched on whole octets or groups: `"10.1"` and `"10.1."` match `10.1.x.x` but not
265
+ `10.100.x.x`. IPv6 prefixes compare against the compressed form (`2001:db8::5`), so prefer CIDR.
266
+
267
+ IPv4-mapped (`::ffff:a.b.c.d`) and NAT64 (`64:ff9b::a.b.c.d`) hops are unwrapped to IPv4 before matching.
268
+ Misconfiguration raises `ValueError` at construction: a bare string instead of a list, empty or
269
+ non-IP entries, an invalid CIDR, or a negative or non-integer `proxy_count`. `proxy_count` and
270
+ `proxy_list` may both be set: the list fixes the client position, and the count is a hop-count
271
+ requirement (a minimum, or exact when `strict=True`).
272
+
273
+ ```python
274
+ ipw = IpWare(proxy_list=["198.84.193.157"]) # one proxy
275
+ ipw = IpWare(proxy_list=["198.84.193.157", "198.84.193.158"]) # two proxies
276
+ ipw = IpWare(proxy_list=["177.139.", "177.140"]) # prefixes for dynamic IPs
277
+ ipw = IpWare(proxy_list=["100.64.0.0/10"]) # CIDR network (IPv4 or IPv6)
278
+
279
+ # non-strict — X-Forwarded-For: <fake>, <client>, <proxy1>, <proxy2>
280
+ ip, trusted_route = ipw.get_client_ip(request.META)
281
+
282
+ # strict — X-Forwarded-For must be exactly: <client>, <proxy1>, <proxy2>
283
+ ip, trusted_route = ipw.get_client_ip(request.META, strict=True)
284
+ ```
285
+
286
+ ```mermaid
287
+ flowchart LR
288
+ RC["Real client<br/>8.8.8.8"] --> LB["Trusted proxy<br/>198.84.193.157"]
289
+ LB -->|"XFF: 8.8.8.8, 198.84.193.157"| APP["Your app<br/>proxy_list: 198.84.193.157"]
290
+ FC["Fake client<br/>5.6.7.8"] -->|"bypasses the proxy<br/>XFF: 1.2.3.4 (forged)"| APP
291
+ APP --> OK["Real request: (8.8.8.8, True)"]
292
+ APP --> NO["Fake request: (None, False)"]
293
+ ```
294
+
295
+ ## Proxy count
296
+
297
+ If you know how many proxies are in front of you but not their IPs (for example, across providers):
298
+
299
+ ```python
300
+ ipw = IpWare(proxy_count=2)
301
+
302
+ # non-strict — at least 2 proxies
303
+ ip, trusted_route = ipw.get_client_ip(request.META)
304
+
305
+ # strict — exactly 2 proxies: <client>, <proxy1>, <proxy2>
306
+ ip, trusted_route = ipw.get_client_ip(request.META, strict=True)
307
+ ```
308
+
309
+ ```mermaid
310
+ flowchart LR
311
+ C["Client<br/>8.8.8.8"] --> P1["Proxy 1<br/>104.16.0.1"] --> P2["Proxy 2<br/>34.120.0.1"] --> APP["Your app<br/>proxy_count=2"]
312
+ APP --> H1["XFF: 8.8.8.8, 104.16.0.1, 34.120.0.1<br/>returns (8.8.8.8, True)"]
313
+ APP --> H2["XFF: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1<br/>forged prefix ignored: (8.8.8.8, True)<br/>strict=True: (None, False)"]
314
+ ```
315
+
316
+ Combine both for the tightest check:
317
+
318
+ ```python
319
+ ipw = IpWare(proxy_count=1, proxy_list=["198.84.193.157"])
320
+ ```
321
+
322
+ ## Right-most client networks
323
+
324
+ The [de-facto standard](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) puts the
325
+ originating client left-most. For the rare network that puts it right-most:
326
+
327
+ ```python
328
+ ipw = IpWare(leftmost=False)
329
+ ```
330
+
331
+ ```mermaid
332
+ flowchart LR
333
+ S["Standard: client, proxy1, proxy2"] -->|"leftmost=True (default)"| A["client = first entry"]
334
+ R["Reversed: proxy2, proxy1, client"] -->|"leftmost=False"| B["client = last entry"]
335
+ ```
336
+
337
+ See [docs/nginx.md](https://github.com/un33k/python-ipware/blob/main/docs/nginx.md) for an NGINX configuration example.
338
+
339
+ ## Development
340
+
341
+ ```sh
342
+ python -m pip install -e '.[dev]'
343
+ ruff check .
344
+ python -m unittest discover -s tests -p "tests_*.py" # full suite
345
+ python -m tests.legacy.run_against_legacy # v3 suite against the legacy engine
346
+ python -m build && python -m twine check dist/*
347
+ ```
348
+
349
+ ## License
350
+
351
+ Released under the [MIT](https://github.com/un33k/python-ipware/blob/main/LICENSE) license.
352
+
353
+ ## Maintenance
354
+
355
+ `python-ipware` is actively maintained with [Dojo](https://heydojo.ai) ⛩️. The legacy engine is frozen for
356
+ backward compatibility; all improvements target the modern engine. Need support? Reach
357
+ [Neekware Inc.](https://neekware.com) at info@neekware.com.
358
+
359
+ ## Sponsors
360
+
361
+ [Neekware Inc.](https://neekware.com) — creator of [Dojo Workspace](https://heydojo.ai), your AI workspace for building, learning, and getting things done.
362
+
363
+ 🚀 Created with [Dojo](https://heydojo.ai) ⛩️
364
+
365
+ [status-image]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml/badge.svg
366
+ [status-link]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml
367
+ [version-image]: https://img.shields.io/pypi/v/python-ipware.svg
368
+ [version-link]: https://pypi.org/project/python-ipware/
369
+ [coverage-image]: https://coveralls.io/repos/github/un33k/python-ipware/badge.svg?branch=main
370
+ [coverage-link]: https://coveralls.io/github/un33k/python-ipware?branch=main
371
+ [maintained-image]: https://img.shields.io/badge/maintained%20with-Dojo%20%E2%9B%A9%EF%B8%8F-1f2937
372
+ [maintained-link]: https://heydojo.ai
@@ -0,0 +1,15 @@
1
+ python_ipware/__init__.py,sha256=BuZ3sY_v_GTeXmATXqNc_GqRGVDLGP0mj8pWj_StKrA,199
2
+ python_ipware/__version__.py,sha256=jegJMYuSW90VKiMotVdNAakBvYVPirm-A5oeyJVuhH8,22
3
+ python_ipware/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ python_ipware/router.py,sha256=ZLEBbBW3xoOfrvwFfBx_V8Bp2rF1tA1jAEjSY5Rnbqo,3097
5
+ python_ipware/legacy/README.md,sha256=h33u11bhkvmU3IJMnPE0p6lXI3f6QiYN1RrI3eIFHsk,14519
6
+ python_ipware/legacy/__init__.py,sha256=NuIwZ_yvPVJudz4WttTc9piiPlvxAlS1iooRyX2Kc1w,397
7
+ python_ipware/legacy/engine.py,sha256=IotMZQIGSOu2XCbmdYH4WuhBvXm-lJVJHe3FXylSMvc,12725
8
+ python_ipware/modern/__init__.py,sha256=uzxoWK47NOaeYVizW2-G7pQSXzQ1Zq9fSu2kru9sR70,109
9
+ python_ipware/modern/defaults.py,sha256=pZkXt6iMVUwfklpBzFQFF36Xd2n1_8Xej7VViBW3spY,2032
10
+ python_ipware/modern/engine.py,sha256=7CQL7O9z4dY85k_YmoyW4TxDjtdHODKS9wIZz2Amnjw,10942
11
+ python_ipware/modern/parsers.py,sha256=qHAR5SQwVapsj8zrb76W3yCuybvJJB7enJDUE8XGtag,8081
12
+ python_ipware-4.1.0.dist-info/METADATA,sha256=H_NvIBXdDmpo0L0KlX0jcaEoqixlMwSoPaGW7lrLx7Q,15650
13
+ python_ipware-4.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ python_ipware-4.1.0.dist-info/licenses/LICENSE,sha256=MLpNxpqfTc4TLdcDk3x6k7Vz4lJGBNLV-SxQZlFMDU8,1103
15
+ python_ipware-4.1.0.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
-
@@ -1,9 +0,0 @@
1
- python_ipware/__init__.py,sha256=TCZx4mt5Gxr2D_zNU6j0ynopdsAN_v4xeiTWz1OXSpY,87
2
- python_ipware/__version__.py,sha256=EPmgXOdWKks5S__ZMH7Nu6xpAeVrZpfxaFy4pykuyeI,22
3
- python_ipware/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- python_ipware/python_ipware.py,sha256=IotMZQIGSOu2XCbmdYH4WuhBvXm-lJVJHe3FXylSMvc,12725
5
- python_ipware-3.0.0.dist-info/LICENSE,sha256=MLpNxpqfTc4TLdcDk3x6k7Vz4lJGBNLV-SxQZlFMDU8,1103
6
- python_ipware-3.0.0.dist-info/METADATA,sha256=FMUPREojTP2twlxzbgzD7toAgvmKkbcxuzemFjpFUMY,15420
7
- python_ipware-3.0.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
8
- python_ipware-3.0.0.dist-info/top_level.txt,sha256=URE4dvjpReJtRHyQpJqoBow5EWt_RqVXFn85B9Tq5Xw,14
9
- python_ipware-3.0.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
- python_ipware
File without changes