python-ipware 2.0.5__tar.gz → 4.1.0__tar.gz
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-4.1.0/.gitignore +60 -0
- python_ipware-4.1.0/CHANGELOG.md +152 -0
- python_ipware-4.1.0/PKG-INFO +372 -0
- python_ipware-4.1.0/README.md +330 -0
- python_ipware-4.1.0/SECURITY.md +18 -0
- python_ipware-4.1.0/pyproject.toml +126 -0
- python_ipware-4.1.0/python_ipware/__init__.py +6 -0
- python_ipware-4.1.0/python_ipware/__version__.py +1 -0
- {python_ipware-2.0.5 → python_ipware-4.1.0/python_ipware/legacy}/README.md +13 -0
- python_ipware-4.1.0/python_ipware/legacy/__init__.py +12 -0
- python_ipware-4.1.0/python_ipware/modern/__init__.py +5 -0
- python_ipware-4.1.0/python_ipware/modern/defaults.py +52 -0
- python_ipware-4.1.0/python_ipware/modern/engine.py +256 -0
- python_ipware-4.1.0/python_ipware/modern/parsers.py +210 -0
- python_ipware-4.1.0/python_ipware/router.py +83 -0
- python_ipware-4.1.0/tests/__init__.py +0 -0
- python_ipware-4.1.0/tests/legacy/__init__.py +0 -0
- python_ipware-4.1.0/tests/legacy/run_against_legacy.py +41 -0
- python_ipware-4.1.0/tests/tests_modern_config.py +274 -0
- python_ipware-4.1.0/tests/tests_modern_matrix.py +391 -0
- python_ipware-4.1.0/tests/tests_router.py +367 -0
- python_ipware-2.0.5/PKG-INFO +0 -344
- python_ipware-2.0.5/pyproject.toml +0 -90
- python_ipware-2.0.5/python_ipware/__init__.py +0 -2
- python_ipware-2.0.5/python_ipware/__version__.py +0 -1
- python_ipware-2.0.5/python_ipware.egg-info/PKG-INFO +0 -344
- python_ipware-2.0.5/python_ipware.egg-info/SOURCES.txt +0 -14
- python_ipware-2.0.5/python_ipware.egg-info/dependency_links.txt +0 -1
- python_ipware-2.0.5/python_ipware.egg-info/requires.txt +0 -6
- python_ipware-2.0.5/python_ipware.egg-info/top_level.txt +0 -1
- python_ipware-2.0.5/setup.cfg +0 -4
- {python_ipware-2.0.5 → python_ipware-4.1.0}/LICENSE +0 -0
- /python_ipware-2.0.5/python_ipware/python_ipware.py → /python_ipware-4.1.0/python_ipware/legacy/engine.py +0 -0
- {python_ipware-2.0.5 → python_ipware-4.1.0}/python_ipware/py.typed +0 -0
- {python_ipware-2.0.5/tests → python_ipware-4.1.0/tests/legacy}/tests_ipv4.py +0 -0
- {python_ipware-2.0.5/tests → python_ipware-4.1.0/tests/legacy}/tests_ipv6.py +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
|
|
5
|
+
# C extensions
|
|
6
|
+
*.so
|
|
7
|
+
|
|
8
|
+
# Distribution / packaging
|
|
9
|
+
.Python
|
|
10
|
+
env/
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
*.egg-info/
|
|
23
|
+
.installed.cfg
|
|
24
|
+
*.egg
|
|
25
|
+
|
|
26
|
+
# PyInstaller
|
|
27
|
+
# Usually these files are written by a python script from a template
|
|
28
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
29
|
+
*.manifest
|
|
30
|
+
*.spec
|
|
31
|
+
|
|
32
|
+
# Installer logs
|
|
33
|
+
pip-log.txt
|
|
34
|
+
pip-delete-this-directory.txt
|
|
35
|
+
|
|
36
|
+
# Unit test / coverage reports
|
|
37
|
+
htmlcov/
|
|
38
|
+
.tox/
|
|
39
|
+
.coverage
|
|
40
|
+
.coverage.*
|
|
41
|
+
.cache
|
|
42
|
+
nosetests.xml
|
|
43
|
+
coverage.xml
|
|
44
|
+
*,cover
|
|
45
|
+
|
|
46
|
+
# Translations
|
|
47
|
+
*.mo
|
|
48
|
+
*.pot
|
|
49
|
+
|
|
50
|
+
# Django stuff:
|
|
51
|
+
*.log
|
|
52
|
+
|
|
53
|
+
# Sphinx documentation
|
|
54
|
+
docs/_build/
|
|
55
|
+
|
|
56
|
+
# PyBuilder
|
|
57
|
+
target/
|
|
58
|
+
|
|
59
|
+
.DS_Store
|
|
60
|
+
.ruff_cache
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
## 4.1.0
|
|
2
|
+
|
|
3
|
+
Best match (modern engine only; legacy is unchanged). Results can differ from 4.0.0 on well-formed input,
|
|
4
|
+
always toward a better address; use `algorithm="legacy"` for exact v3 results:
|
|
5
|
+
- Addresses are ranked public > private > link-local > loopback. Unspecified (`0.0.0.0`, `::`),
|
|
6
|
+
multicast, broadcast, and reserved addresses are never returned. Python reports multicast as
|
|
7
|
+
`is_global`, so v3 could return `224.0.0.1` as the client.
|
|
8
|
+
- Without `proxy_count` / `proxy_list`, the first public hop of a chain wins, not only the first hop:
|
|
9
|
+
`10.0.0.1, 177.139.233.139` now yields `177.139.233.139`. With `leftmost=False` the scan runs from the
|
|
10
|
+
right. With proxy settings, the client position is fixed exactly as before. Note that the public hop
|
|
11
|
+
may be an upstream proxy: if you must identify private (intranet / VPN) clients, set `proxy_count` or
|
|
12
|
+
`proxy_list`.
|
|
13
|
+
- NAT64 well-known-prefix addresses (`64:ff9b::a.b.c.d`, RFC 6052) are unwrapped to the embedded IPv4
|
|
14
|
+
client, like IPv4-mapped addresses. v3 returned the IPv6 form.
|
|
15
|
+
- `trusted_route` is `True` for any address resolved through a matching proxy config, including private
|
|
16
|
+
clients; v3 reported `False` for them.
|
|
17
|
+
- New exhaustive tests: every 1–3 hop chain over ten address kinds, in every leftmost / strict / proxy
|
|
18
|
+
configuration, checked against an independent reference model; every header assignment and dict order;
|
|
19
|
+
every spelling of a hop; never-worse-than-legacy across the whole matrix; seeded fuzzing.
|
|
20
|
+
|
|
21
|
+
Enhance (modern engine only; legacy is unchanged):
|
|
22
|
+
- Parse RFC 7239 `Forwarded` elements by their `for=` value, including quoted, bracketed IPv6 with a port.
|
|
23
|
+
Previously `Forwarded` never produced an IP, so when it is present it can now resolve at its existing
|
|
24
|
+
precedence slot, which is above the CDN headers. Like `X-Forwarded-For`, a client can send it; behind a
|
|
25
|
+
CDN, pass an explicit `precedence` naming that CDN's header. Obfuscated hops (`for=unknown`, `for=_hidden`) count as invalid tokens.
|
|
26
|
+
- New default headers, added only between the 4.0.0 entries and `REMOTE_ADDR`, so none outranks a header
|
|
27
|
+
that resolved requests before: Azure Front Door `X-Azure-ClientIP`, DigitalOcean `DO-Connecting-IP`,
|
|
28
|
+
Envoy/Istio `X-Envoy-External-Address`, plus the missing `HTTP_X_CLIENT_IP` and raw `X-AppEngine-User-IP`
|
|
29
|
+
forms of headers already on the list.
|
|
30
|
+
- Header names match case-insensitively (`-` and `_` equivalent), so lowercase keys such as AWS Lambda's
|
|
31
|
+
work. Exact keys still take priority. When several spellings fold to the same header, the dash spelling
|
|
32
|
+
wins, whatever the dict order, so a client-sent `x_forwarded_for` cannot shadow the proxy's
|
|
33
|
+
`x-forwarded-for`. Dash spellings that disagree are treated as absent.
|
|
34
|
+
|
|
35
|
+
Harden (modern engine only):
|
|
36
|
+
- Reject malformed tokens instead of truncating them: unclosed brackets (`[::1`), text after a bracket
|
|
37
|
+
(`[::1]junk`), and non-numeric, empty, or out-of-range ports (`1.2.3.4:abc`, `1.2.3.4:70000`). Note v3
|
|
38
|
+
accepted `1.2.3.4:abc` as `1.2.3.4`.
|
|
39
|
+
- Non-string header values (`None`, bytes) are skipped instead of raising `AttributeError`.
|
|
40
|
+
- `proxy_list` entries are stripped of whitespace. An empty entry now raises `ValueError`: it used to match
|
|
41
|
+
every address and mark any spoofed chain as trusted.
|
|
42
|
+
- Trusted-proxy matching is tighter. A complete IP entry is matched exactly: v3 prefix-matched it, so
|
|
43
|
+
`"1.2.3.4"` also trusted `1.2.3.45`, letting that host forge the client IP. Prefixes match on whole
|
|
44
|
+
octets or groups (`"10.1"` matches `10.1.x.x`, not `10.100.x.x`). IPv6 entries are case- and
|
|
45
|
+
zero-insensitive. An entry ending in `:` stays a prefix, so `"2001:db8::"` behaves as in 4.0.0.
|
|
46
|
+
IPv4-mapped and NAT64 CIDR entries (`::ffff:10.0.0.0/104`) match the unwrapped IPv4 hops.
|
|
47
|
+
- Configuration errors raise `ValueError` at construction instead of misbehaving silently: `proxy_list`
|
|
48
|
+
or `precedence` passed as a bare string (each character became an entry), a non-IP `proxy_list` entry
|
|
49
|
+
such as `"foo"`, or a `proxy_count` that is a bool, a float, or a string. `precedence` and `proxy_list`
|
|
50
|
+
are copied, so later changes to the caller's lists have no effect.
|
|
51
|
+
- A `meta` that is not a mapping raises `TypeError` with a clear message. Non-string keys are skipped.
|
|
52
|
+
- RFC 7239 `Forwarded` parsing is quote-aware: a `,` or `;` inside a quoted value no longer splits a hop,
|
|
53
|
+
so `ext="x,8.8.8.8"` cannot smuggle in a fake address or a fake proxy hop.
|
|
54
|
+
- More address classes: `0.0.0.0/8` is never returned. Deprecated site-local `fec0::/10` ranks as
|
|
55
|
+
private, since Python reports it as global. RFC 8215 local-use NAT64 `64:ff9b:1::/48` ranks as private,
|
|
56
|
+
since Python reports it as reserved.
|
|
57
|
+
- The unused `is_valid_ip` helper was removed from `python_ipware.modern.parsers`. It was never exported.
|
|
58
|
+
- Test suite: 100% line and branch coverage of the modern engine.
|
|
59
|
+
|
|
60
|
+
CI:
|
|
61
|
+
- Bump `actions/upload-artifact` to v7 and `actions/download-artifact` to v8, which run on Node 24.
|
|
62
|
+
|
|
63
|
+
## 4.0.0
|
|
64
|
+
|
|
65
|
+
Community (thank you!):
|
|
66
|
+
- Trusted proxies in `proxy_list` can now be CIDR networks, IPv4 or IPv6 (e.g. `100.64.0.0/10`), matched by
|
|
67
|
+
real network membership; plain prefixes still work. Modern engine only. Requested by @griffi-gh (#26).
|
|
68
|
+
- Added Fly.io's `Fly-Client-IP` header to the modern default precedence. Suggested by @mdalp (#23).
|
|
69
|
+
- README now shows how to put a CDN header such as Cloudflare's first via `precedence`, when all traffic
|
|
70
|
+
comes through that CDN. Suggested by @iloveitaly (#25).
|
|
71
|
+
- CI covers Python 3.13 and newer. Suggested by @iloveitaly (#24).
|
|
72
|
+
|
|
73
|
+
Enhance:
|
|
74
|
+
- Introduce a pluggable algorithm router: `IpWare(algorithm=...)` with `"auto"` (default), `"modern"`, and `"legacy"`.
|
|
75
|
+
- `"auto"` resolves to `"modern"` — the enhanced engine and the forward-moving default.
|
|
76
|
+
- `"legacy"` is an explicit escape hatch that runs the frozen v3 algorithm byte-for-byte.
|
|
77
|
+
- New `modern` engine: hardened IPv6 / bracketed-port / IPv4-mapped parsing and an expanded default header
|
|
78
|
+
precedence list (adds `True-Client-IP`, `Fastly-Client-IP`, App Engine, Azure `X-Client-IP`).
|
|
79
|
+
- The frozen v3 algorithm is preserved unchanged under `python_ipware.legacy`; the original v3 test suite
|
|
80
|
+
passes against both the legacy and modern engines.
|
|
81
|
+
|
|
82
|
+
Modernize:
|
|
83
|
+
- Migrate packaging to PEP 621 with the Hatchling build backend; version is read from `__version__.py`.
|
|
84
|
+
- Drop end-of-life Python 3.7 / 3.8; requires Python 3.9+, tested on 3.9–3.14.
|
|
85
|
+
- Bump ruff config to the `lint.*` table layout.
|
|
86
|
+
|
|
87
|
+
Note:
|
|
88
|
+
- No source change is required for existing users: `from python_ipware import IpWare` continues to work and
|
|
89
|
+
now defaults to the modern engine. On well-formed headers modern returns the same result as v3 (the full
|
|
90
|
+
v3 suite and a legacy-vs-modern differential test pass). It differs only on malformed values:
|
|
91
|
+
- quoted addresses such as `"1.2.3.4"` are accepted (v3 ignored them);
|
|
92
|
+
- a value with more than one port-like suffix, such as `1.2.3.4:80:90`, is rejected (v3 took `1.2.3.4`).
|
|
93
|
+
Use `IpWare(algorithm="legacy")` if you depend on the exact v3 handling of those inputs.
|
|
94
|
+
|
|
95
|
+
## 3.0.0
|
|
96
|
+
|
|
97
|
+
Fix:
|
|
98
|
+
- Release major version, as there is a possibility of api change causing minimal backward incompatibly
|
|
99
|
+
|
|
100
|
+
## 2.0.5
|
|
101
|
+
|
|
102
|
+
Enhance:
|
|
103
|
+
- AI assisted clean up
|
|
104
|
+
|
|
105
|
+
## 2.0.4
|
|
106
|
+
|
|
107
|
+
Enhance:
|
|
108
|
+
- Added `proxy_count=0` as an option (@FraKraBa)
|
|
109
|
+
|
|
110
|
+
## 2.0.3
|
|
111
|
+
|
|
112
|
+
Enhance:
|
|
113
|
+
- Added `HTTP_CF_CONNECTING_IP` to list of known ip headers (Adam M.)
|
|
114
|
+
|
|
115
|
+
## 2.0.2
|
|
116
|
+
|
|
117
|
+
Enhance:
|
|
118
|
+
- Added logger name
|
|
119
|
+
|
|
120
|
+
## 2.0.1
|
|
121
|
+
|
|
122
|
+
Issue:
|
|
123
|
+
- Remove `HTTP_VIA` header support (unreliable IP information) (@yourcelf)
|
|
124
|
+
|
|
125
|
+
Enhance:
|
|
126
|
+
- Include support for python 3.12
|
|
127
|
+
|
|
128
|
+
## 2.0.0
|
|
129
|
+
|
|
130
|
+
- Introduced breaking changes to avoid conflicts with the `django-ipware` package.
|
|
131
|
+
- Renamed the imported module from `ipware` to `python_ipware` in the `python-ipware` package.
|
|
132
|
+
- Old usage: `from ipware import IpWare`
|
|
133
|
+
- New usage: `from python_ipware import IpWare`
|
|
134
|
+
|
|
135
|
+
## 1.0.5
|
|
136
|
+
|
|
137
|
+
- Enhance: Readme updates
|
|
138
|
+
|
|
139
|
+
## 1.0.0
|
|
140
|
+
|
|
141
|
+
Features:
|
|
142
|
+
|
|
143
|
+
- Added `X-CLIENT-IP` header support
|
|
144
|
+
- Adds PEP 561 Compatibility (@stumpylog)
|
|
145
|
+
- Streamline pyproject.toml & add trusted publishing (@stumpylog)
|
|
146
|
+
- Publish version 1.0.0
|
|
147
|
+
|
|
148
|
+
## 0.9.0
|
|
149
|
+
|
|
150
|
+
Features:
|
|
151
|
+
|
|
152
|
+
- Initial Release
|
|
@@ -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
|