python-ipware 2.0.5__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 +6 -2
- python_ipware/__version__.py +1 -1
- python_ipware-2.0.5.dist-info/METADATA → python_ipware/legacy/README.md +12 -30
- python_ipware/legacy/__init__.py +12 -0
- python_ipware/modern/__init__.py +5 -0
- python_ipware/modern/defaults.py +52 -0
- python_ipware/modern/engine.py +256 -0
- python_ipware/modern/parsers.py +210 -0
- python_ipware/router.py +83 -0
- python_ipware-4.1.0.dist-info/METADATA +372 -0
- python_ipware-4.1.0.dist-info/RECORD +15 -0
- {python_ipware-2.0.5.dist-info → python_ipware-4.1.0.dist-info}/WHEEL +1 -2
- python_ipware-2.0.5.dist-info/RECORD +0 -9
- python_ipware-2.0.5.dist-info/top_level.txt +0 -1
- /python_ipware/{python_ipware.py → legacy/engine.py} +0 -0
- {python_ipware-2.0.5.dist-info → python_ipware-4.1.0.dist-info/licenses}/LICENSE +0 -0
python_ipware/__init__.py
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
from .
|
|
2
|
-
from .
|
|
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__"]
|
python_ipware/__version__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "
|
|
1
|
+
__version__ = "4.1.0"
|
|
@@ -1,33 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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,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
|
python_ipware/router.py
ADDED
|
@@ -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,9 +0,0 @@
|
|
|
1
|
-
python_ipware/__init__.py,sha256=TCZx4mt5Gxr2D_zNU6j0ynopdsAN_v4xeiTWz1OXSpY,87
|
|
2
|
-
python_ipware/__version__.py,sha256=xEb7Z4b8xalXXExBg42XPAhbJKniHzcsEPjp-6S3ppg,22
|
|
3
|
-
python_ipware/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
python_ipware/python_ipware.py,sha256=IotMZQIGSOu2XCbmdYH4WuhBvXm-lJVJHe3FXylSMvc,12725
|
|
5
|
-
python_ipware-2.0.5.dist-info/LICENSE,sha256=MLpNxpqfTc4TLdcDk3x6k7Vz4lJGBNLV-SxQZlFMDU8,1103
|
|
6
|
-
python_ipware-2.0.5.dist-info/METADATA,sha256=3Lsxn8HubcN0KnuwggZLcDu5h67rZc5YOn5ad-YNNdY,15420
|
|
7
|
-
python_ipware-2.0.5.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
8
|
-
python_ipware-2.0.5.dist-info/top_level.txt,sha256=URE4dvjpReJtRHyQpJqoBow5EWt_RqVXFn85B9Tq5Xw,14
|
|
9
|
-
python_ipware-2.0.5.dist-info/RECORD,,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
python_ipware
|
|
File without changes
|
|
File without changes
|