diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b91cbd5..fb67e56 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,51 +13,75 @@ on:
- ci
- dev
- main
+ workflow_dispatch:
jobs:
ci:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
- python-version: [3.7, 3.8, 3.9, "3.10", 3.11, 3.12, pypy3.9]
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10"]
steps:
- - uses: actions/checkout@v3
- - name: setup python
- uses: actions/setup-python@v4
+ - uses: actions/checkout@v5
+ - name: Set up Python
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
+ cache-dependency-path: pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install -e .[dev]
+ pip install -e ".[test]" coveralls
- name: Run ruff
run: ruff check .
- - name: Run test
- run: coverage run --source=python_ipware -m unittest discover
+ - name: Run tests (modern default + router)
+ run: coverage run -m unittest discover -s tests -p "tests_*.py"
+ - name: Run frozen v3 tests against the legacy engine
+ run: python -m tests.legacy.run_against_legacy
- name: Coveralls
+ # Coverage upload is informational; a Coveralls outage must not fail CI.
+ continue-on-error: true
run: coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COVERALLS_FLAG_NAME: python-${{ matrix.python-version }}
+ COVERALLS_PARALLEL: true
+
+ coveralls-finish:
+ name: Coveralls finish
+ needs: ci
+ runs-on: ubuntu-latest
+ steps:
+ - name: Finish parallel coverage
+ uses: coverallsapp/github-action@v2
+ with:
+ parallel-finished: true
+ fail-on-error: false
+
build:
name: Build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- - uses: actions/checkout@v4
- - name: Set up Python 3.10
- uses: actions/setup-python@v4
+ - uses: actions/checkout@v5
+ - name: Set up Python
+ uses: actions/setup-python@v6
with:
- python-version: '3.10'
- cache: 'pip'
+ python-version: "3.12"
+ cache: "pip"
+ cache-dependency-path: pyproject.toml
- name: Install build tools
- run: pip3 --quiet install --upgrade build wheel
+ run: python -m pip install --upgrade build twine
- name: Build
- run: python3 -m build .
- - uses: actions/upload-artifact@v3
+ run: python -m build
+ - name: Check distribution metadata
+ run: python -m twine check dist/*
+ - uses: actions/upload-artifact@v4
with:
name: artifacts
path: dist/*
@@ -69,14 +93,14 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
- id-token: write # IMPORTANT: mandatory for trusted publishing
+ id-token: write # mandatory for trusted publishing
needs:
- build
- ci
steps:
- - uses: actions/download-artifact@v3
+ - uses: actions/download-artifact@v4
with:
name: artifacts
path: dist
- name: Publish build to PyPI
- uses: pypa/gh-action-pypi-publish@v1.8.10
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18f586b..9a95130 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,35 @@
+## 4.0.0
+
+Community (thank you!):
+- Trusted proxies in `proxy_list` can now be CIDR networks, IPv4 or IPv6 (e.g. `100.64.0.0/10`), matched by
+ real network membership; plain prefixes still work. Modern engine only. Requested by @griffi-gh (#26).
+- Added Fly.io's `Fly-Client-IP` header to the modern default precedence. Suggested by @mdalp (#23).
+- README now shows how to put a CDN header such as Cloudflare's first via `precedence`, when all traffic
+ comes through that CDN. Suggested by @iloveitaly (#25).
+- CI covers Python 3.13 and newer. Suggested by @iloveitaly (#24).
+
+Enhance:
+- Introduce a pluggable algorithm router: `IpWare(algorithm=...)` with `"auto"` (default), `"modern"`, and `"legacy"`.
+ - `"auto"` resolves to `"modern"` — the enhanced engine and the forward-moving default.
+ - `"legacy"` is an explicit escape hatch that runs the frozen v3 algorithm byte-for-byte.
+- New `modern` engine: hardened IPv6 / bracketed-port / IPv4-mapped parsing and an expanded default header
+ precedence list (adds `True-Client-IP`, `Fastly-Client-IP`, App Engine, Azure `X-Client-IP`).
+- The frozen v3 algorithm is preserved unchanged under `python_ipware.legacy`; the original v3 test suite
+ passes against both the legacy and modern engines.
+
+Modernize:
+- Migrate packaging to PEP 621 with the Hatchling build backend; version is read from `__version__.py`.
+- Drop end-of-life Python 3.7 / 3.8; requires Python 3.9+, tested on 3.9–3.14.
+- Bump ruff config to the `lint.*` table layout.
+
+Note:
+- No source change is required for existing users: `from python_ipware import IpWare` continues to work and
+ now defaults to the modern engine. On well-formed headers modern returns the same result as v3 (the full
+ v3 suite and a legacy-vs-modern differential test pass). It differs only on malformed values:
+ - quoted addresses such as `"1.2.3.4"` are accepted (v3 ignored them);
+ - a value with more than one port-like suffix, such as `1.2.3.4:80:90`, is rejected (v3 took `1.2.3.4`).
+ Use `IpWare(algorithm="legacy")` if you depend on the exact v3 handling of those inputs.
+
## 3.0.0
Fix:
diff --git a/README.md b/README.md
index b218bc2..aecbee9 100644
--- a/README.md
+++ b/README.md
@@ -1,313 +1,275 @@
-# Python IPware (A Python Package)
+# Python IPware
-**A python package for server applications to retrieve client's IP address**
+Best-effort client IP detection for Python server applications — Django, Flask, or any WSGI/ASGI framework.
[![status-image]][status-link]
[![version-image]][version-link]
[![coverage-image]][coverage-link]
+[![maintained-image]][maintained-link]
-# Overview
+## Quickstart
-**Best attempt** to get client's IP address while keeping it **DRY**.
-
-# Notice
-
-### Addressing IP Address Spoofing
-
-There is no perfect `out-of-the-box` solution to counteract fake IP addresses, or IP Address Spoofing. We strongly recommend reading the [Advanced Users](README.md#advanced-users) section. Utilize the `proxy_list` and `proxy_count` features to adapt the functionality to your specific requirements, especially if you plan to incorporate `python-ipware` into authentication, security, or anti-fraud systems.
-
-### Open Source Considerations
-
-Keep in mind that `python-ipware` is an open-source project, meaning its source code is accessible to everyone. While this openness promotes community engagement and scrutiny, it also exposes the code to potential exploiters who might take advantage of unimplemented or improperly implemented features.
-
-### Complementary Security Measure
-
-Use `python-ipware` **only** as an additional layer to bolster your security, not as a primary defense mechanism. Always pair it with robust firewall security protocols to ensure comprehensive protection against a variety of security threats, including IP spoofing.
-
-# How to install
-
-```
-pip install python-ipware
-```
--- or --
-```
-pip3 install python-ipware
+```sh
+python -m pip install --upgrade python-ipware
```
-# How to use
-
-### Using python-ipware to Retrieve Client IP in Django or Flask
-
-Here's a basic example of how to use `python-ipware` in a view or middleware where the `request` object is available. This can be applied in Django, Flask, or other similar frameworks.
-
```python
from python_ipware import IpWare
-# Instantiate IpWare with default values
ipw = IpWare()
-# Get the META data from the request object
-meta = request.META # Django
-# meta = request.environ # Flask
-
-# Get the client IP and the trusted route flag
-ip, trusted_route = ipw.get_client_ip(meta)
+# Django: request.META | Flask: request.environ
+ip, trusted_route = ipw.get_client_ip(request.META)
if ip:
- # The 'ip' is an object of type IPv4Address() or IPv6Address() with properties like:
- # - ip.is_global: True if the IP is globally routable
- # - ip.is_private: True if the IP is a private address
- # - ip.is_loopback: True if the IP is a loopback address
- # - ip.is_multicast: True if the IP is a multicast address
- # - ip.is_unspecified: True if the IP is an unspecified address
- # - ip.is_reserved: True if the IP is a reserved address
+ # ip is an ipaddress.IPv4Address or IPv6Address
+ ip.is_global # publicly routable
+ ip.is_private # private network
+ ip.is_loopback # 127.0.0.1 / ::1
if trusted_route:
- # Indicates if the request came through our trusted proxies
-
-# You can now use the IP address as needed, for example, attaching it to the request object.
-# Consider caching the IP address for performance, as it doesn't change often.
-# It's also advisable to have distinct session IDs for public and anonymous users to cache the IP address effectively.
+ # the request came through your configured proxies (proxy_count / proxy_list)
+ ...
```
-# Advanced users:
-
-| Params ⇩ | ⇩ Description |
-| --------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `proxy_count` ⇨ | : Total number of expected proxies (pattern: `client, proxy1, ..., proxy2`)
: if `proxy_count = 0` then `client`
: if `proxy_count = 1` then `client, proxy1`
: if `proxy_count = 2` then `client, proxy1, proxy2`
: if `proxy_count = 3` then `client, proxy1, proxy2 proxy3` |
-| `proxy_list` ⇨ | : List of trusted proxies (ip header pattern: `client, proxy1, ,..., proxyN`)
: if `proxy_list = ['10.1.']` then `client, proxy1`
: if `proxy_list = ['10.1', '10.2.3']` then `client, proxy1 proxy2`
: if `proxy_list = ['10.1', '10.2.', '10.3.4.4']` then `client, proxy1, proxy2, proxy3` |
-| `leftmost` ⇨ | : `leftmost = True` is default for de-facto standard.
: `leftmost = False` for rare legacy networks that are configured with the `rightmost` pattern.
: It converts `client, proxy1 proxy2` to `proxy2, proxy1, client` |
+Python 3.9+ is supported (tested on 3.9 – 3.14), with no upper version cap. No runtime dependencies.
+On a newer Python that isn't in the test matrix yet? It should just work — if it doesn't,
+[open an issue](https://github.com/un33k/python-ipware/issues) and we'll fix it.
-| Output ⇩ | ⇩ Description |
-| ----------------: | :------------------------------------------------------------------------------------------- |
-| `ip` ⇨ | : Client IP address object of type IPv4Address() or IPv6Address() |
-| `trusted_route` ⇨ | : If proxy `proxy_count` and/or `proxy_list` were provided and matched, `True`, else `False` |
+> **Legacy:** the frozen 3.x algorithm is still available with `IpWare(algorithm="legacy")`.
+> See the [legacy guide](https://github.com/un33k/python-ipware/blob/main/python_ipware/legacy/README.md).
-### Precedence Order
+## What it's used for
-The client IP address can be found in one or more request headers attributes. The lookup order is top to bottom and the default attributes are as follow.
+```mermaid
+flowchart LR
+ R["Incoming request"] --> I["IpWare().get_client_ip(...)"]
+ I --> RL["Rate limiting and throttling"]
+ I --> GEO["Geo-location and localization"]
+ I --> LOG["Audit and access logs"]
+ I --> FR["Abuse and fraud signals
(check trusted_route)"]
+ I --> AUTH["Login anomaly checks
(check trusted_route)"]
+```
-```python
-# The default meta precedence order - you can be more specific as per your configuration
-# It will start looking through the request headers from top to bottom to find the best match
-# It will return the first qualified global (public) ip address it finds, else
-# It will return the first qualified private ip address it finds, else
-# It will return the first qualified loopback up address it finds, else it returns None
-# Update as per your network topology, reduce the numbers and/or reorder the list
-request_headers_precedence_order = (
- "X_FORWARDED_FOR", # Load balancers or proxies such as AWS ELB (default client is `left-most` [`, , `])
- "HTTP_X_FORWARDED_FOR", # Similar to X_FORWARDED_TO
- "HTTP_CLIENT_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
- "HTTP_X_REAL_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
- "HTTP_X_FORWARDED", # Squid and others
- "HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB and Riverbed Stingray
- "HTTP_FORWARDED_FOR", # RFC 7239
- "HTTP_FORWARDED", # RFC 7239
- "HTTP_CF_CONNECTING_IP", # CloudFlare
- "X-CLIENT-IP", # Microsoft Azure
- "X-REAL-IP", # NGINX
- "X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
- "X_FORWARDED", # Squid
- "FORWARDED_FOR", # RFC 7239
- "CF-CONNECTING-IP", # CloudFlare
- "TRUE-CLIENT-IP", # CloudFlare Enterprise,
- "FASTLY-CLIENT-IP", # Firebase, Fastly
- "FORWARDED", # RFC 7239
- "CLIENT-IP", # Akamai and Cloudflare: True-Client-IP and Fastly: Fastly-Client-IP
- "REMOTE_ADDR", # Default
-)
+## Security notice
+
+> **Found a security issue?** Please email **info@neekware.com** privately — do not open a public
+> issue or pull request. See [SECURITY.md](https://github.com/un33k/python-ipware/blob/main/SECURITY.md).
+
+There is no perfect defense against IP address spoofing. Headers such as `X-Forwarded-For` are set by
+clients and proxies, and can be forged. If you use `python-ipware` for authentication, rate limiting, or
+anti-fraud, configure `proxy_count` and/or `proxy_list` for your network topology and treat it as one
+layer alongside your firewall — never as the only defense.
+
+```mermaid
+sequenceDiagram
+ participant A as Attacker (real IP 8.8.8.8)
+ participant P as Your proxy chain
+ participant App as Your app
+ A->>P: X-Forwarded-For: 1.2.3.4 (forged)
+ P->>App: X-Forwarded-For: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1
+ Note over App: IpWare() trusts the left-most entry and returns 1.2.3.4 (spoofed)
+ Note over App: IpWare(proxy_count=2) counts from the right and returns 8.8.8.8
+ Note over App: Adding strict=True rejects the tampered header entirely
```
-You can customize the order by providing your own list during initialization when calling `IpWare()`.
+## API
```python
-# specific meta key
-ipw = IpWare(precedence=("X_FORWARDED_FOR"))
+IpWare(
+ precedence=None, # tuple of header keys to check, in order
+ leftmost=True, # client is the left-most IP in the chain
+ proxy_count=None, # expected number of proxies in front of your server
+ proxy_list=None, # trusted proxies: CIDR networks or IP prefixes
+)
-# multiple meta keys
-ipw = IpWare(precedence=("X_FORWARDED_FOR", "HTTP_X_FORWARDED_FOR"))
+ip, trusted_route = ipw.get_client_ip(meta, strict=False)
+```
-# Django (request.META)
-ip, proxy_verified = ipw.get_client_ip(meta=request.META)
+| Parameter | Description |
+| --- | --- |
+| `precedence` | Header keys to search, top to bottom. Defaults to the list below. |
+| `leftmost` | `True` (default) follows the de-facto `client, proxy1, proxy2` order. Use `False` only for networks that put the client right-most. |
+| `proxy_count` | Number of proxies expected after the client. `0` is valid; `None` disables the check. |
+| `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"`) or a plain IP prefix (`"10.1."`, `"198.84.193.157"`). |
+| `strict` | `False`: at least `proxy_count` / `proxy_list` proxies. `True`: exactly that many — extra or invalid entries reject the header. |
+
+| Output | Description |
+| --- | --- |
+| `ip` | `IPv4Address`, `IPv6Address`, or `None` |
+| `trusted_route` | `True` when `proxy_count` or `proxy_list` was configured and matched |
+
+### Selection rules
+
+Headers are checked in precedence order. The first **public** IP found wins; otherwise the first
+**private** IP; otherwise the first **loopback** IP; otherwise `None`.
+
+```mermaid
+flowchart TD
+ A["Request headers (...)"] --> B["Take the next header in precedence order"]
+ B --> C{"Header present?"}
+ C -->|no| B
+ C -->|yes| D["Split the chain: client, proxy1, proxy2"]
+ D --> E{"Matches proxy_count and proxy_list?"}
+ E -->|no| B
+ E -->|yes| F["Pick the client entry"]
+ F --> G{"Public IP?"}
+ G -->|yes| H["Return (ip, trusted_route)"]
+ G -->|no| I["Keep as private or loopback fallback"]
+ I --> B
+ B -->|no headers left| J["Return first private, else loopback, else None"]
+```
-# Flask (request.environ)
-ip, proxy_verified = ipw.get_client_ip(meta=request.environ)
+Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`) and IPv4-mapped IPv6 addresses
+(`::ffff:1.2.3.4`) are returned as plain IPv4.
-# ... etc.
+## Default header precedence
+```python
+(
+ "X_FORWARDED_FOR", # load balancers / proxies (AWS ELB, etc.)
+ "HTTP_X_FORWARDED_FOR",
+ "HTTP_CLIENT_IP", # Amazon EC2, Heroku
+ "HTTP_X_REAL_IP",
+ "HTTP_X_FORWARDED", # Squid
+ "HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB, Riverbed Stingray
+ "HTTP_FORWARDED_FOR", # RFC 7239
+ "HTTP_FORWARDED", # RFC 7239
+ "HTTP_CF_CONNECTING_IP", # Cloudflare
+ "HTTP_TRUE_CLIENT_IP", # Cloudflare Enterprise, Akamai
+ "HTTP_FASTLY_CLIENT_IP", # Fastly, Firebase
+ "HTTP_FLY_CLIENT_IP", # Fly.io
+ "HTTP_X_APPENGINE_USER_IP", # Google App Engine
+ "X-CLIENT-IP", # Microsoft Azure
+ "X-REAL-IP", # NGINX
+ "X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
+ "X_FORWARDED",
+ "FORWARDED_FOR",
+ "CF-CONNECTING-IP",
+ "TRUE-CLIENT-IP",
+ "FASTLY-CLIENT-IP",
+ "FLY-CLIENT-IP",
+ "FORWARDED",
+ "CLIENT-IP",
+ "REMOTE_ADDR", # direct connection
+)
```
-### Trusted Proxies
+Narrow it to what your infrastructure actually sets:
-If your node server is behind one or more known proxy server(s), you can filter out unwanted requests
-by providing a `trusted proxy list`, or a known proxy `count`.
+```python
+ipw = IpWare(precedence=("HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))
+```
-You can customize the proxy IP prefixes by providing your own list during initialization when calling `IpWare(proxy_list)`.
-You can pass your custom list on every call, when calling the proxy-aware api to fetch the ip.
+If **all** your traffic comes through a CDN, put its header first. Only do this when the app is not
+reachable directly, because clients can send these headers themselves:
```python
-# In the above scenario, use your load balancer IP address as a way to filter out unwanted requests.
-ipw = IpWare(proxy_list=["198.84.193.157"])
-
+# Behind Cloudflare only
+ipw = IpWare(precedence=("HTTP_CF_CONNECTING_IP", "HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"))
+```
-# If you have multiple proxies, simply add them to the list
-ipw = IpWare(proxy_list=["198.84.193.157", "198.84.193.158"])
+## Trusted proxies
-# For proxy servers with fixed sub-domain and dynamic IP, use the following pattern.
-ipw = IpWare(proxy_list=["177.139.", "177.140"])
+If your server sits behind known proxies, pass their IPs or prefixes:
-# usage: non-strict mode (X-Forwarded-For: , , , )
-# The request went through our and , then our server
-# We choose the ip address to the left our and ignore other ips
-ip, trusted_route = ipw.get_client_ip(meta=request.META)
+```python
+ipw = IpWare(proxy_list=["198.84.193.157"]) # one proxy
+ipw = IpWare(proxy_list=["198.84.193.157", "198.84.193.158"]) # two proxies
+ipw = IpWare(proxy_list=["177.139.", "177.140"]) # prefixes for dynamic IPs
+ipw = IpWare(proxy_list=["100.64.0.0/10"]) # CIDR network (IPv4 or IPv6)
+# non-strict — X-Forwarded-For: , , ,
+ip, trusted_route = ipw.get_client_ip(request.META)
-# usage: strict mode (X-Forwarded-For: , , )
-# The request went through our and , then our server
-# Total ip address are total trusted proxies + client ip
-# We don't allow far-end proxies, or fake addresses (exact or None)
-ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
+# strict — X-Forwarded-For must be exactly: , ,
+ip, trusted_route = ipw.get_client_ip(request.META, strict=True)
```
-In the following `example`, your public load balancer (LB) can be seen as a `trusted` proxy.
-
-```
-`Real` Client <-> LB (Server) <-----> Django Server
- ^
- |
-`Fake` Client <-> LB (Server) -+
+```mermaid
+flowchart LR
+ RC["Real client
8.8.8.8"] --> LB["Trusted proxy
198.84.193.157"]
+ LB -->|"XFF: 8.8.8.8, 198.84.193.157"| APP["Your app
proxy_list: 198.84.193.157"]
+ FC["Fake client
5.6.7.8"] -->|"bypasses the proxy
XFF: 1.2.3.4 (forged)"| APP
+ APP --> OK["Real request: (8.8.8.8, True)"]
+ APP --> NO["Fake request: (None, False)"]
```
-### Proxy Count
+## Proxy count
-If your python server is behind a `known` number of proxies, but you deploy on multiple providers and don't want to track proxy IPs, you still can filter out unwanted requests by providing proxy `count`.
-
-You can customize the proxy count by providing your `proxy_count` during initialization when calling `IpWare(proxy_count=2)`.
+If you know how many proxies are in front of you but not their IPs (for example, across providers):
```python
-from python_ipware import IpWare
-
-# Enforce proxy count
-# proxy_count=0 is valid
-# proxy_count=None to disable proxy_count check
ipw = IpWare(proxy_count=2)
-# Example usage in non-strict mode:
-# X-Forwarded-For format: , , ,
-# At least `proxy_count` number of proxies
-ip, trusted_route = ipw.get_client_ip(meta=request.META)
+# non-strict — at least 2 proxies
+ip, trusted_route = ipw.get_client_ip(request.META)
-# Example usage in strict mode:
-# X-Forwarded-For format: , ,
-# Exact `proxy_count` number of proxies
-ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
+# strict — exactly 2 proxies: , ,
+ip, trusted_route = ipw.get_client_ip(request.META, strict=True)
```
-### Proxy Count & Trusted Proxy List Combo
-In this example, we utilize the total number of proxies as a method to filter out unwanted requests while verifying the trust proxies.
+```mermaid
+flowchart LR
+ C["Client
8.8.8.8"] --> P1["Proxy 1
104.16.0.1"] --> P2["Proxy 2
34.120.0.1"] --> APP["Your app
proxy_count=2"]
+ APP --> H1["XFF: 8.8.8.8, 104.16.0.1, 34.120.0.1
returns (8.8.8.8, True)"]
+ APP --> H2["XFF: 1.2.3.4, 8.8.8.8, 104.16.0.1, 34.120.0.1
forged prefix ignored: (8.8.8.8, True)
strict=True: (None, False)"]
+```
-```python
-from python_ipware import IpWare
+Combine both for the tightest check:
-# Enforce both proxy count and trusted proxies
+```python
ipw = IpWare(proxy_count=1, proxy_list=["198.84.193.157"])
-
-# Example usage in non-strict mode:
-# X-Forwarded-For format: , , ,
-# At least `proxy_count` number of proxies
-ip, trusted_route = ipw.get_client_ip(meta=request.META)
-
-# Example usage in strict mode:
-# X-Forwarded-For format: ,
-# Exact `proxy_count` number of proxies
-ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
```
-In the following `example`, your public load balancer (LB) can be seen as the `only` proxy.
-
-```
-`Real` Client <-> LB (Server) <---> Node Server
- ^
- |
- `Fake` Client ---+
-```
+## Right-most client networks
-### Support for Public IP Address (routable on the internet), Private and Loopback
+The [de-facto standard](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) puts the
+originating client left-most. For the rare network that puts it right-most:
```python
-# We make best attempt to return the first public IP address based on header precedence
-# Then we fall back on private, followed by loopback
-from python_ipware import IpWare
-
-# no proxy enforce in this example
-ipw = IpWare()
-
-ip, _ = ipw.get_client_ip(meta=request.META)
-
-if ip.is_global:
- print('Public IP')
-else if ip.is_private:
- print('Private IP')
-else if ip.is_loopback:
- print('Loopback IP')
-else if ip.is_multicast:
- print('Multicast IP')
-else if ip.is_unspecified:
- print('Unspecified IP')
-else if ip.is_reserved:
- print('Reserved IP')
+ipw = IpWare(leftmost=False)
```
+```mermaid
+flowchart LR
+ S["Standard: client, proxy1, proxy2"] -->|"leftmost=True (default)"| A["client = first entry"]
+ R["Reversed: proxy2, proxy1, client"] -->|"leftmost=False"| B["client = last entry"]
+```
-### IP Address Handling
-
-#### Support for IPv4, IPv6, and IP:Port Patterns
-
-`python-ipware` is designed to handle various IP address formats efficiently:
-
-- **Ports Stripping:** Automatically removes ports from IP addresses, ensuring only the IP is processed.
-- **IPv6 Unwrapping:** Extracts and processes IPv4 addresses wrapped in IPv6 containers.
-
-#### Identifying the Originating IP Address
-
-The [de-facto standard](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) for identifying the originating client IP address is to use the `leftmost` IP in the `X-Forwarded-For` header, following the pattern `client, proxy1, proxy2`. Here, the `rightmost` IP is considered the most trusted proxy.
-
-##### Custom Network Configurations
-
-In some rare scenarios, networks might be configured such that the `rightmost` IP address represents the originating client. In such cases, instantiate `IpWare` with the `leftmost=False` parameter:
+See [docs/nginx.md](https://github.com/un33k/python-ipware/blob/main/docs/nginx.md) for an NGINX configuration example.
+## Development
-# Running the tests
+```sh
+python -m pip install -e '.[dev]'
+ruff check .
+python -m unittest discover -s tests -p "tests_*.py" # full suite
+python -m tests.legacy.run_against_legacy # v3 suite against the legacy engine
+python -m build && python -m twine check dist/*
+```
-To run the tests against the current environment:
+## License
- ./test.sh
+Released under the [MIT](https://github.com/un33k/python-ipware/blob/main/LICENSE) license.
-# License
+## Maintenance
-Released under a ([MIT](https:#raw.githubusercontent.com/un33k/python-ipware/main/LICENSE)) license.
+`python-ipware` is actively maintained with [Dojo](https://heydojo.ai) ⛩️. The legacy engine is frozen for
+backward compatibility; all improvements target the modern engine. Need support? Reach
+[Neekware Inc.](https://neekware.com) at info@neekware.com.
-# Version
+## Sponsors
-X.Y.Z Version
+[Neekware Inc.](https://neekware.com) — creator of [Dojo Workspace](https://heydojo.ai), your AI workspace for building, learning, and getting things done.
- `MAJOR` version -- making incompatible API changes
- `MINOR` version -- adding functionality in a backwards-compatible manner
- `PATCH` version -- making backwards-compatible bug fixes
+🚀 Created with [Dojo](https://heydojo.ai) ⛩️
[status-image]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml/badge.svg
[status-link]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml
[version-image]: https://img.shields.io/pypi/v/python-ipware.svg
-[version-link]: https://pypi.python.org/pypi/python-ipware?branch=main
+[version-link]: https://pypi.org/project/python-ipware/
[coverage-image]: https://coveralls.io/repos/github/un33k/python-ipware/badge.svg?branch=main
[coverage-link]: https://coveralls.io/github/un33k/python-ipware?branch=main
-[download-image]: https://img.shields.io/pypi/dm/python-ipware.svg
-[download-link]: https://pypi.python.org/pypi/python-ipware
-
-# Sponsors
-
-[Neekware Inc.](http://neekware.com)
-
-# Need Support?
-
-[Neekware Inc.](http://neekware.com) (reach out at info@neekware.com)
+[maintained-image]: https://img.shields.io/badge/maintained%20with-Dojo%20%E2%9B%A9%EF%B8%8F-1f2937
+[maintained-link]: https://heydojo.ai
diff --git a/RELEASE_NOTES_v4.0.0.md b/RELEASE_NOTES_v4.0.0.md
new file mode 100644
index 0000000..68c9cf5
--- /dev/null
+++ b/RELEASE_NOTES_v4.0.0.md
@@ -0,0 +1,43 @@
+# python-ipware 4.0.0
+
+> DRAFT — for review only. Not published. When ready, this becomes the GitHub
+> release body for tag `v4.0.0`.
+
+A major release that introduces a pluggable algorithm router and modernizes the
+packaging. The new **modern** engine is the default. On well-formed headers it
+returns the same result as v3 — the full v3 test suite and a legacy-vs-modern
+differential test pass against it. It differs only on malformed values (quoted
+addresses are accepted; `1.2.3.4:80:90` is rejected). The **legacy** v3
+algorithm is preserved byte-for-byte as an explicit escape hatch.
+
+## Highlights
+
+- **Algorithm router.** `IpWare(algorithm=...)` selects the engine:
+ - `modern` (new default, also via `auto`) — hardened IPv6 / bracketed-port /
+ IPv4-mapped parsing and an expanded default header precedence list
+ (adds True-Client-IP, Fastly, App Engine, Azure).
+ - `legacy` — the exact v3 algorithm, frozen byte-for-byte. Requested explicitly only.
+- **Modern packaging.** PEP 621 `pyproject.toml` with the Hatchling backend,
+ dynamic version, SPDX license metadata, refreshed classifiers.
+- **Cleaner modern code.** Native builtin generics (`list`, `dict`, `tuple`);
+ no `from __future__ import annotations`.
+
+## Python support
+
+Python 3.9+ (tested on 3.9 – 3.14). Dropped end-of-life Python 3.7 and 3.8.
+
+## Upgrading
+
+Existing `IpWare()` callers get the modern engine by default. If you require
+byte-for-byte v3 results, pin the legacy engine:
+
+```python
+from python_ipware import IpWare
+
+ipw = IpWare(algorithm="legacy")
+ip, trusted = ipw.get_client_ip(request.META)
+```
+
+**Full Changelog**: https://github.com/un33k/python-ipware/compare/v3.0.0...v4.0.0
+
+🚀 Generated with [Dojo](https://heydojo.ai) ⛩️
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..f5cfa6f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,18 @@
+# Security Policy
+
+## Reporting a vulnerability
+
+**Please do not report security issues through public GitHub issues, pull requests, or discussions.**
+
+Email **info@neekware.com** privately instead, with:
+
+- a description of the issue and its impact
+- steps or a minimal example to reproduce it
+- the affected version(s), and the `algorithm` in use (`modern` or `legacy`)
+
+We will acknowledge your report, investigate, and coordinate a fix and release before any
+public disclosure. Please give us a reasonable window to ship the fix before you share details.
+
+## Supported versions
+
+Security fixes target the latest release.
diff --git a/pyproject.toml b/pyproject.toml
index ad4c5e9..d65566c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,64 +1,100 @@
[build-system]
-requires = [
- "setuptools>=42",
- "wheel"
-]
-build-backend = "setuptools.build_meta"
+requires = ["hatchling"]
+build-backend = "hatchling.build"
[project]
name = "python-ipware"
+description = "A Python package to retrieve a client's real IP address."
+readme = "README.md"
+requires-python = ">=3.9"
+license = "MIT"
+license-files = ["LICENSE"]
authors = [
- {name = "Val Neekman", email = "info@neekware.com"},
+ { name = "Val Neekman", email = "info@neekware.com" },
+]
+maintainers = [
+ { name = "Val Neekman", email = "info@neekware.com" },
+]
+keywords = [
+ "python",
+ "ip",
+ "ipware",
+ "client ip",
+ "real ip",
+ "remote addr",
+ "x-forwarded-for",
+ "proxy",
]
-description = "A Python package to retrieve user's IP address"
-requires-python = ">=3.7"
-dynamic = ["version", "readme"]
-license = {text = "MIT"}
+dynamic = ["version"]
classifiers = [
- "Development Status :: 4 - Beta",
+ "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Natural Language :: English",
"License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Typing :: Typed",
]
+dependencies = []
[project.urls]
+Homepage = "https://github.com/un33k/python-ipware"
Documentation = "https://github.com/un33k/python-ipware#readme"
+Repository = "https://github.com/un33k/python-ipware"
Issues = "https://github.com/un33k/python-ipware/issues"
-Source = "https://github.com/un33k/python-ipware"
Changelog = "https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md"
[project.optional-dependencies]
+# Lint + tests only: installs cleanly on every interpreter, including PyPy.
+test = [
+ "ruff==0.16.8", # pinned so new lint rules cannot break CI unannounced
+ "coverage[toml]",
+]
+# Full maintainer toolchain (adds packaging tools, whose deps don't build on PyPy).
dev = [
- "ruff",
- "coveralls~=3.3",
- "coverage[toml]",
- "twine"
+ "python-ipware[test]",
+ "build",
+ "twine",
]
-[tool.setuptools]
-packages = ["python_ipware"]
+[tool.hatch.version]
+path = "python_ipware/__version__.py"
-[tool.setuptools.dynamic]
-version = {attr = "python_ipware.__version__"}
-readme = {file = ["README.md"], content-type = "text/markdown"}
+[tool.hatch.build.targets.wheel]
+packages = ["python_ipware"]
-[tool.setuptools.package-data]
-"python_ipware" = ["py.typed"]
+[tool.hatch.build.targets.sdist]
+include = [
+ "python_ipware",
+ "tests",
+ "README.md",
+ "CHANGELOG.md",
+ "SECURITY.md",
+ "LICENSE",
+]
[tool.ruff]
+line-length = 120
+target-version = "py39"
+# The legacy engine and its v3 tests are frozen byte-for-byte, so newer lint rules
+# must not force edits there.
+extend-exclude = ["python_ipware/legacy", "tests/legacy"]
+
+[tool.ruff.lint]
select = [
"B",
"C4",
"C9",
- "DJ",
"E",
"EM",
"F",
@@ -78,13 +114,13 @@ select = [
"W",
]
ignore = ["PGH004", "TID252", "E501", "I001", "EM101", "SIM108", "SIM110"]
-line-length = 120
-target-version = "py37"
-[tool.ruff.mccabe]
+[tool.ruff.lint.mccabe]
max-complexity = 16
[tool.coverage.run]
-omit = [
- "python_ipware/__version__.py"
-]
\ No newline at end of file
+source = ["python_ipware"]
+omit = ["python_ipware/__version__.py"]
+
+[tool.coverage.report]
+show_missing = true
diff --git a/python_ipware/__init__.py b/python_ipware/__init__.py
index 5ec16c9..df2f501 100644
--- a/python_ipware/__init__.py
+++ b/python_ipware/__init__.py
@@ -1,2 +1,6 @@
-from .python_ipware import IpWare # noqa
-from .__version__ import __version__ # noqa
+from .__version__ import __version__
+from .legacy import LegacyIpWare
+from .modern import ModernIpWare
+from .router import IpWare
+
+__all__ = ["IpWare", "LegacyIpWare", "ModernIpWare", "__version__"]
diff --git a/python_ipware/__version__.py b/python_ipware/__version__.py
index 528787c..ce1305b 100644
--- a/python_ipware/__version__.py
+++ b/python_ipware/__version__.py
@@ -1 +1 @@
-__version__ = "3.0.0"
+__version__ = "4.0.0"
diff --git a/python_ipware/legacy/README.md b/python_ipware/legacy/README.md
new file mode 100644
index 0000000..b420f6f
--- /dev/null
+++ b/python_ipware/legacy/README.md
@@ -0,0 +1,326 @@
+# Legacy engine (v3) — how to use
+
+> 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).
+
+```python
+from python_ipware import IpWare
+
+ipw = IpWare(algorithm="legacy")
+ip, trusted_route = ipw.get_client_ip(request.META)
+```
+
+---
+
+# Python IPware (A Python Package)
+
+**A python package for server applications to retrieve client's IP address**
+
+[![status-image]][status-link]
+[![version-image]][version-link]
+[![coverage-image]][coverage-link]
+
+# Overview
+
+**Best attempt** to get client's IP address while keeping it **DRY**.
+
+# Notice
+
+### Addressing IP Address Spoofing
+
+There is no perfect `out-of-the-box` solution to counteract fake IP addresses, or IP Address Spoofing. We strongly recommend reading the [Advanced Users](README.md#advanced-users) section. Utilize the `proxy_list` and `proxy_count` features to adapt the functionality to your specific requirements, especially if you plan to incorporate `python-ipware` into authentication, security, or anti-fraud systems.
+
+### Open Source Considerations
+
+Keep in mind that `python-ipware` is an open-source project, meaning its source code is accessible to everyone. While this openness promotes community engagement and scrutiny, it also exposes the code to potential exploiters who might take advantage of unimplemented or improperly implemented features.
+
+### Complementary Security Measure
+
+Use `python-ipware` **only** as an additional layer to bolster your security, not as a primary defense mechanism. Always pair it with robust firewall security protocols to ensure comprehensive protection against a variety of security threats, including IP spoofing.
+
+# How to install
+
+```
+pip install python-ipware
+```
+-- or --
+```
+pip3 install python-ipware
+```
+
+# How to use
+
+### Using python-ipware to Retrieve Client IP in Django or Flask
+
+Here's a basic example of how to use `python-ipware` in a view or middleware where the `request` object is available. This can be applied in Django, Flask, or other similar frameworks.
+
+```python
+from python_ipware import IpWare
+
+# Instantiate IpWare with default values
+ipw = IpWare()
+
+# Get the META data from the request object
+meta = request.META # Django
+# meta = request.environ # Flask
+
+# Get the client IP and the trusted route flag
+ip, trusted_route = ipw.get_client_ip(meta)
+
+if ip:
+ # The 'ip' is an object of type IPv4Address() or IPv6Address() with properties like:
+ # - ip.is_global: True if the IP is globally routable
+ # - ip.is_private: True if the IP is a private address
+ # - ip.is_loopback: True if the IP is a loopback address
+ # - ip.is_multicast: True if the IP is a multicast address
+ # - ip.is_unspecified: True if the IP is an unspecified address
+ # - ip.is_reserved: True if the IP is a reserved address
+
+if trusted_route:
+ # Indicates if the request came through our trusted proxies
+
+# You can now use the IP address as needed, for example, attaching it to the request object.
+# Consider caching the IP address for performance, as it doesn't change often.
+# It's also advisable to have distinct session IDs for public and anonymous users to cache the IP address effectively.
+```
+
+# Advanced users:
+
+| Params ⇩ | ⇩ Description |
+| --------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `proxy_count` ⇨ | : Total number of expected proxies (pattern: `client, proxy1, ..., proxy2`)
: if `proxy_count = 0` then `client`
: if `proxy_count = 1` then `client, proxy1`
: if `proxy_count = 2` then `client, proxy1, proxy2`
: if `proxy_count = 3` then `client, proxy1, proxy2 proxy3` |
+| `proxy_list` ⇨ | : List of trusted proxies (ip header pattern: `client, proxy1, ,..., proxyN`)
: if `proxy_list = ['10.1.']` then `client, proxy1`
: if `proxy_list = ['10.1', '10.2.3']` then `client, proxy1 proxy2`
: if `proxy_list = ['10.1', '10.2.', '10.3.4.4']` then `client, proxy1, proxy2, proxy3` |
+| `leftmost` ⇨ | : `leftmost = True` is default for de-facto standard.
: `leftmost = False` for rare legacy networks that are configured with the `rightmost` pattern.
: It converts `client, proxy1 proxy2` to `proxy2, proxy1, client` |
+
+| Output ⇩ | ⇩ Description |
+| ----------------: | :------------------------------------------------------------------------------------------- |
+| `ip` ⇨ | : Client IP address object of type IPv4Address() or IPv6Address() |
+| `trusted_route` ⇨ | : If proxy `proxy_count` and/or `proxy_list` were provided and matched, `True`, else `False` |
+
+### Precedence Order
+
+The client IP address can be found in one or more request headers attributes. The lookup order is top to bottom and the default attributes are as follow.
+
+```python
+# The default meta precedence order - you can be more specific as per your configuration
+# It will start looking through the request headers from top to bottom to find the best match
+# It will return the first qualified global (public) ip address it finds, else
+# It will return the first qualified private ip address it finds, else
+# It will return the first qualified loopback up address it finds, else it returns None
+# Update as per your network topology, reduce the numbers and/or reorder the list
+request_headers_precedence_order = (
+ "X_FORWARDED_FOR", # Load balancers or proxies such as AWS ELB (default client is `left-most` [`, , `])
+ "HTTP_X_FORWARDED_FOR", # Similar to X_FORWARDED_TO
+ "HTTP_CLIENT_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
+ "HTTP_X_REAL_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
+ "HTTP_X_FORWARDED", # Squid and others
+ "HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB and Riverbed Stingray
+ "HTTP_FORWARDED_FOR", # RFC 7239
+ "HTTP_FORWARDED", # RFC 7239
+ "HTTP_CF_CONNECTING_IP", # CloudFlare
+ "X-CLIENT-IP", # Microsoft Azure
+ "X-REAL-IP", # NGINX
+ "X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
+ "X_FORWARDED", # Squid
+ "FORWARDED_FOR", # RFC 7239
+ "CF-CONNECTING-IP", # CloudFlare
+ "TRUE-CLIENT-IP", # CloudFlare Enterprise,
+ "FASTLY-CLIENT-IP", # Firebase, Fastly
+ "FORWARDED", # RFC 7239
+ "CLIENT-IP", # Akamai and Cloudflare: True-Client-IP and Fastly: Fastly-Client-IP
+ "REMOTE_ADDR", # Default
+)
+```
+
+You can customize the order by providing your own list during initialization when calling `IpWare()`.
+
+```python
+# specific meta key
+ipw = IpWare(precedence=("X_FORWARDED_FOR"))
+
+# multiple meta keys
+ipw = IpWare(precedence=("X_FORWARDED_FOR", "HTTP_X_FORWARDED_FOR"))
+
+# Django (request.META)
+ip, proxy_verified = ipw.get_client_ip(meta=request.META)
+
+# Flask (request.environ)
+ip, proxy_verified = ipw.get_client_ip(meta=request.environ)
+
+# ... etc.
+
+```
+
+### Trusted Proxies
+
+If your node server is behind one or more known proxy server(s), you can filter out unwanted requests
+by providing a `trusted proxy list`, or a known proxy `count`.
+
+You can customize the proxy IP prefixes by providing your own list during initialization when calling `IpWare(proxy_list)`.
+You can pass your custom list on every call, when calling the proxy-aware api to fetch the ip.
+
+```python
+# In the above scenario, use your load balancer IP address as a way to filter out unwanted requests.
+ipw = IpWare(proxy_list=["198.84.193.157"])
+
+
+# If you have multiple proxies, simply add them to the list
+ipw = IpWare(proxy_list=["198.84.193.157", "198.84.193.158"])
+
+# For proxy servers with fixed sub-domain and dynamic IP, use the following pattern.
+ipw = IpWare(proxy_list=["177.139.", "177.140"])
+
+# usage: non-strict mode (X-Forwarded-For: , , , )
+# The request went through our and , then our server
+# We choose the ip address to the left our and ignore other ips
+ip, trusted_route = ipw.get_client_ip(meta=request.META)
+
+
+# usage: strict mode (X-Forwarded-For: , , )
+# The request went through our and , then our server
+# Total ip address are total trusted proxies + client ip
+# We don't allow far-end proxies, or fake addresses (exact or None)
+ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
+```
+
+In the following `example`, your public load balancer (LB) can be seen as a `trusted` proxy.
+
+```
+`Real` Client <-> LB (Server) <-----> Django Server
+ ^
+ |
+`Fake` Client <-> LB (Server) -+
+```
+
+### Proxy Count
+
+If your python server is behind a `known` number of proxies, but you deploy on multiple providers and don't want to track proxy IPs, you still can filter out unwanted requests by providing proxy `count`.
+
+You can customize the proxy count by providing your `proxy_count` during initialization when calling `IpWare(proxy_count=2)`.
+
+```python
+from python_ipware import IpWare
+
+# Enforce proxy count
+# proxy_count=0 is valid
+# proxy_count=None to disable proxy_count check
+ipw = IpWare(proxy_count=2)
+
+# Example usage in non-strict mode:
+# X-Forwarded-For format: , , ,
+# At least `proxy_count` number of proxies
+ip, trusted_route = ipw.get_client_ip(meta=request.META)
+
+# Example usage in strict mode:
+# X-Forwarded-For format: , ,
+# Exact `proxy_count` number of proxies
+ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
+```
+
+### Proxy Count & Trusted Proxy List Combo
+In this example, we utilize the total number of proxies as a method to filter out unwanted requests while verifying the trust proxies.
+
+```python
+from python_ipware import IpWare
+
+# Enforce both proxy count and trusted proxies
+ipw = IpWare(proxy_count=1, proxy_list=["198.84.193.157"])
+
+# Example usage in non-strict mode:
+# X-Forwarded-For format: , , ,
+# At least `proxy_count` number of proxies
+ip, trusted_route = ipw.get_client_ip(meta=request.META)
+
+# Example usage in strict mode:
+# X-Forwarded-For format: ,
+# Exact `proxy_count` number of proxies
+ip, trusted_route = ipw.get_client_ip(meta=request.META, strict=True)
+```
+
+In the following `example`, your public load balancer (LB) can be seen as the `only` proxy.
+
+```
+`Real` Client <-> LB (Server) <---> Node Server
+ ^
+ |
+ `Fake` Client ---+
+```
+
+### Support for Public IP Address (routable on the internet), Private and Loopback
+
+```python
+# We make best attempt to return the first public IP address based on header precedence
+# Then we fall back on private, followed by loopback
+from python_ipware import IpWare
+
+# no proxy enforce in this example
+ipw = IpWare()
+
+ip, _ = ipw.get_client_ip(meta=request.META)
+
+if ip.is_global:
+ print('Public IP')
+else if ip.is_private:
+ print('Private IP')
+else if ip.is_loopback:
+ print('Loopback IP')
+else if ip.is_multicast:
+ print('Multicast IP')
+else if ip.is_unspecified:
+ print('Unspecified IP')
+else if ip.is_reserved:
+ print('Reserved IP')
+```
+
+
+### IP Address Handling
+
+#### Support for IPv4, IPv6, and IP:Port Patterns
+
+`python-ipware` is designed to handle various IP address formats efficiently:
+
+- **Ports Stripping:** Automatically removes ports from IP addresses, ensuring only the IP is processed.
+- **IPv6 Unwrapping:** Extracts and processes IPv4 addresses wrapped in IPv6 containers.
+
+#### Identifying the Originating IP Address
+
+The [de-facto standard](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) for identifying the originating client IP address is to use the `leftmost` IP in the `X-Forwarded-For` header, following the pattern `client, proxy1, proxy2`. Here, the `rightmost` IP is considered the most trusted proxy.
+
+##### Custom Network Configurations
+
+In some rare scenarios, networks might be configured such that the `rightmost` IP address represents the originating client. In such cases, instantiate `IpWare` with the `leftmost=False` parameter:
+
+
+# Running the tests
+
+To run the tests against the current environment:
+
+ ./test.sh
+
+# License
+
+Released under a ([MIT](https:#raw.githubusercontent.com/un33k/python-ipware/main/LICENSE)) license.
+
+# Version
+
+X.Y.Z Version
+
+ `MAJOR` version -- making incompatible API changes
+ `MINOR` version -- adding functionality in a backwards-compatible manner
+ `PATCH` version -- making backwards-compatible bug fixes
+
+[status-image]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml/badge.svg
+[status-link]: https://github.com/un33k/python-ipware/actions/workflows/ci.yml
+[version-image]: https://img.shields.io/pypi/v/python-ipware.svg
+[version-link]: https://pypi.python.org/pypi/python-ipware?branch=main
+[coverage-image]: https://coveralls.io/repos/github/un33k/python-ipware/badge.svg?branch=main
+[coverage-link]: https://coveralls.io/github/un33k/python-ipware?branch=main
+[download-image]: https://img.shields.io/pypi/dm/python-ipware.svg
+[download-link]: https://pypi.python.org/pypi/python-ipware
+
+# Sponsors
+
+[Neekware Inc.](http://neekware.com)
+
+# Need Support?
+
+[Neekware Inc.](http://neekware.com) (reach out at info@neekware.com)
diff --git a/python_ipware/legacy/__init__.py b/python_ipware/legacy/__init__.py
new file mode 100644
index 0000000..dc8ebdf
--- /dev/null
+++ b/python_ipware/legacy/__init__.py
@@ -0,0 +1,12 @@
+"""Frozen v3 algorithm.
+
+This subpackage preserves the exact behavior of python-ipware 3.x. It is kept
+byte-for-byte stable so that projects upgrading to 4.x can pin
+``algorithm="legacy"`` and get identical results to what they had before.
+
+Do not "improve" this module. New behavior belongs in ``python_ipware.modern``.
+"""
+
+from .engine import IpWare as LegacyIpWare
+
+__all__ = ["LegacyIpWare"]
diff --git a/python_ipware/python_ipware.py b/python_ipware/legacy/engine.py
similarity index 100%
rename from python_ipware/python_ipware.py
rename to python_ipware/legacy/engine.py
diff --git a/python_ipware/modern/__init__.py b/python_ipware/modern/__init__.py
new file mode 100644
index 0000000..6844f4f
--- /dev/null
+++ b/python_ipware/modern/__init__.py
@@ -0,0 +1,5 @@
+"""The modern (v4) python-ipware algorithm."""
+
+from .engine import ModernIpWare
+
+__all__ = ["ModernIpWare"]
diff --git a/python_ipware/modern/defaults.py b/python_ipware/modern/defaults.py
new file mode 100644
index 0000000..fe51141
--- /dev/null
+++ b/python_ipware/modern/defaults.py
@@ -0,0 +1,34 @@
+"""Default header precedence for the modern engine.
+
+Superset of the v3 list, adding widely-deployed CDN/edge headers. Order is
+most-to-least trustworthy for a typical deployment; the first header that
+yields a usable IP wins.
+"""
+
+DEFAULT_PRECEDENCE: tuple[str, ...] = (
+ "X_FORWARDED_FOR",
+ "HTTP_X_FORWARDED_FOR",
+ "HTTP_CLIENT_IP",
+ "HTTP_X_REAL_IP",
+ "HTTP_X_FORWARDED",
+ "HTTP_X_CLUSTER_CLIENT_IP",
+ "HTTP_FORWARDED_FOR",
+ "HTTP_FORWARDED",
+ "HTTP_CF_CONNECTING_IP", # Cloudflare
+ "HTTP_TRUE_CLIENT_IP", # Cloudflare Enterprise / Akamai
+ "HTTP_FASTLY_CLIENT_IP", # Fastly / Firebase
+ "HTTP_FLY_CLIENT_IP", # Fly.io
+ "HTTP_X_APPENGINE_USER_IP", # Google App Engine
+ "X-CLIENT-IP", # Azure
+ "X-REAL-IP", # NGINX
+ "X-CLUSTER-CLIENT-IP", # Rackspace
+ "X_FORWARDED",
+ "FORWARDED_FOR",
+ "CF-CONNECTING-IP",
+ "TRUE-CLIENT-IP",
+ "FASTLY-CLIENT-IP",
+ "FLY-CLIENT-IP",
+ "FORWARDED",
+ "CLIENT-IP",
+ "REMOTE_ADDR",
+)
diff --git a/python_ipware/modern/engine.py b/python_ipware/modern/engine.py
new file mode 100644
index 0000000..5f8fbcd
--- /dev/null
+++ b/python_ipware/modern/engine.py
@@ -0,0 +1,150 @@
+"""The modern python-ipware engine.
+
+Behavior-compatible with the v3 algorithm on the essentials, but cleaner and
+hardened:
+
+* Superset header precedence (adds True-Client-IP, Fastly, App Engine, Azure).
+* Robust IPv6 / bracketed-port / IPv4-mapped parsing.
+* Same "best IP" fallback ladder: prefer a globally routable address; else the
+ first private; else loopback.
+* Same ``strict`` semantics for proxy_count / proxy_list validation.
+* Trusted-proxy matching anchored to the end of the chain. Each ``proxy_list``
+ entry is either a CIDR network (``"100.64.0.0/10"``, ``"fd7a:115c:a1e0::/48"``)
+ matched by real network membership, or a plain string prefix (``"10.1."``).
+"""
+
+import ipaddress
+from typing import Optional, Union
+
+from .defaults import DEFAULT_PRECEDENCE
+from .parsers import IpAddressType, split_proxy_chain
+
+OptionalIp = Optional[IpAddressType]
+IpNetworkType = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
+ProxyMatcher = Union[str, IpNetworkType]
+
+
+def _compile_proxy_matcher(pattern: str) -> ProxyMatcher:
+ """CIDR entries become networks; anything else stays a string prefix."""
+ if "/" not in pattern:
+ return pattern
+ try:
+ # strict=False accepts host bits set, e.g. "10.0.0.5/24" -> 10.0.0.0/24.
+ return ipaddress.ip_network(pattern.strip(), strict=False)
+ except ValueError as exc:
+ msg = f"Invalid CIDR in proxy_list: {pattern!r}"
+ raise ValueError(msg) from exc
+
+
+def _proxy_matches(ip: IpAddressType, matcher: ProxyMatcher) -> bool:
+ if isinstance(matcher, str):
+ return str(ip).startswith(matcher)
+ # Membership across IP versions is simply False, never an error.
+ return ip.version == matcher.version and ip in matcher
+
+
+class ModernIpWare:
+ def __init__(
+ self,
+ precedence: Optional[tuple[str, ...]] = None,
+ leftmost: bool = True,
+ proxy_count: Optional[int] = None,
+ proxy_list: Optional[list[str]] = None,
+ ) -> None:
+ if proxy_count is not None and proxy_count < 0:
+ raise ValueError("proxy_count must be non-negative")
+ if proxy_list is not None and not all(isinstance(p, str) for p in proxy_list):
+ raise ValueError("All elements in the proxy list must be strings.")
+
+ self.precedence = precedence or DEFAULT_PRECEDENCE
+ self.leftmost = leftmost
+ self.proxy_count = proxy_count
+ self.proxy_list = list(proxy_list or [])
+ self._proxy_matchers = [_compile_proxy_matcher(p) for p in self.proxy_list]
+
+ # -- meta access --------------------------------------------------------
+
+ def _get_meta_value(self, meta: dict[str, str], key: str) -> str:
+ meta = meta or {}
+ return meta.get(key, meta.get(key.replace("_", "-"), "")).strip()
+
+ def _get_meta_values(self, meta: dict[str, str]) -> list[str]:
+ values: list[str] = []
+ for key in self.precedence:
+ value = self._get_meta_value(meta, key)
+ if value:
+ values.append(value)
+ return values
+
+ # -- validation ---------------------------------------------------------
+
+ def _proxy_count_valid(self, chain: list[IpAddressType], strict: bool) -> bool:
+ if self.proxy_count is None:
+ return True
+ proxies = len(chain) - 1
+ if strict:
+ return proxies == self.proxy_count
+ return proxies >= self.proxy_count
+
+ def _proxy_list_valid(self, chain: list[IpAddressType], strict: bool) -> bool:
+ if not self.proxy_list:
+ return True
+ count = len(self.proxy_list)
+ if strict and (len(chain) - 1) != count:
+ return False
+ if (len(chain) - 1) < count:
+ return False
+ # Compare the trailing proxies against the trusted entries in order.
+ return all(
+ _proxy_matches(ip, matcher)
+ for ip, matcher in zip(chain[-count:], self._proxy_matchers)
+ )
+
+ # -- selection ----------------------------------------------------------
+
+ def _best_from_chain(self, chain: list[IpAddressType]) -> tuple[OptionalIp, bool]:
+ # ``chain`` is already client-first (see get_client_ip).
+ if not chain:
+ return None, False
+ if self.proxy_list:
+ return chain[-(len(self.proxy_list) + 1)], True
+ if self.proxy_count is not None:
+ return chain[-(self.proxy_count + 1)], True
+ return chain[0], False
+
+ # -- public API ---------------------------------------------------------
+
+ def get_client_ip(
+ self, meta: dict[str, str], strict: bool = False
+ ) -> tuple[OptionalIp, bool]:
+ loopback: list[IpAddressType] = []
+ private: list[IpAddressType] = []
+
+ for raw in self._get_meta_values(meta):
+ chain = split_proxy_chain(raw, strict)
+ if not chain:
+ continue
+ # Put the chain in client-first order ONCE, before any validation, so
+ # the proxy checks and the client pick look at the same end.
+ if not self.leftmost:
+ chain.reverse()
+ if not self._proxy_count_valid(chain, strict):
+ continue
+ if not self._proxy_list_valid(chain, strict):
+ continue
+
+ ip, trusted = self._best_from_chain(chain)
+ if ip is None:
+ continue
+ if ip.is_global:
+ return ip, trusted
+ if ip.is_loopback:
+ loopback.append(ip)
+ else:
+ private.append(ip)
+
+ if private:
+ return private[0], False
+ if loopback:
+ return loopback[0], False
+ return None, False
diff --git a/python_ipware/modern/parsers.py b/python_ipware/modern/parsers.py
new file mode 100644
index 0000000..e1cbf92
--- /dev/null
+++ b/python_ipware/modern/parsers.py
@@ -0,0 +1,76 @@
+"""Framework-agnostic IP parsing helpers for the modern engine.
+
+Pure stdlib. Knows how to clean raw header tokens, strip ports/brackets,
+validate IPv4/IPv6, and split proxy chains.
+"""
+
+import ipaddress
+from typing import Optional, Union
+
+IpAddressType = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
+
+
+def strip_port(value: str) -> str:
+ """Remove a trailing ``:port`` (IPv4) or ``[addr]:port`` (IPv6) suffix."""
+ value = value.strip()
+ if not value:
+ return value
+
+ if value.startswith("["): # [addr] or [addr]:port
+ end = value.find("]")
+ if end != -1:
+ return value[1:end]
+ return value.lstrip("[")
+
+ if value.count(":") == 1: # IPv4:port
+ host, _, _ = value.partition(":")
+ return host
+
+ return value # bare IPv6 or bare IPv4
+
+
+def clean_ip(value: Optional[str]) -> str:
+ """Normalize a raw candidate token into a bare IP string."""
+ if not value:
+ return ""
+ value = value.strip().strip('"').strip("'")
+ value = strip_port(value)
+ return value.strip()
+
+
+def parse_ip(value: Optional[str]) -> Optional[IpAddressType]:
+ """Return a validated ip_address object, or None. Unwraps IPv4-mapped IPv6."""
+ cleaned = clean_ip(value)
+ if not cleaned:
+ return None
+ try:
+ ip = ipaddress.ip_address(cleaned)
+ except ValueError:
+ return None
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
+ return ip.ipv4_mapped
+ return ip
+
+
+def split_proxy_chain(raw: Optional[str], strict: bool = False) -> Optional[list[IpAddressType]]:
+ """Split a comma-separated proxy chain into ordered ``ip_address`` objects.
+
+ Order is preserved left-to-right as it appears in the header. In strict
+ mode, any invalid or empty token makes the whole chain invalid (returns
+ None), since a malformed header should not be trusted. Otherwise invalid
+ and empty tokens are skipped.
+ """
+ if not raw:
+ return []
+ result: list[IpAddressType] = []
+ for token in raw.split(","):
+ ip = parse_ip(token)
+ if ip is not None:
+ result.append(ip)
+ elif strict:
+ return None
+ return result
+
+
+def is_valid_ip(value: Optional[str]) -> bool:
+ return parse_ip(value) is not None
diff --git a/python_ipware/router.py b/python_ipware/router.py
new file mode 100644
index 0000000..d733371
--- /dev/null
+++ b/python_ipware/router.py
@@ -0,0 +1,81 @@
+"""Public ``IpWare`` facade with an algorithm router.
+
+python-ipware 4.x ships two engines:
+
+* ``legacy`` -> the frozen, byte-compatible v3 algorithm.
+* ``modern`` -> the enhanced v4 algorithm (more headers, hardened parsing).
+
+The ``algorithm`` selector chooses between them. ``"auto"`` (the default) is a
+clean alias for ``"modern"`` — the enhanced engine is where development moves
+forward. On well-formed headers it returns the same result as v3 (the full v3
+suite and a legacy-vs-modern differential test pass); it differs only on
+malformed values, as documented in the CHANGELOG. ``legacy`` remains available as an explicit
+escape hatch for projects that need byte-for-byte v3 behavior. There is no
+silent runtime fallback, so behavior stays predictable.
+
+ from python_ipware import IpWare
+
+ IpWare() # auto -> modern (the forward-moving default)
+ IpWare(algorithm="modern") # explicit modern
+ IpWare(algorithm="legacy") # frozen v3 behavior (escape hatch)
+"""
+
+from typing import Literal, Optional
+
+from .legacy import LegacyIpWare
+from .modern import ModernIpWare
+
+Algorithm = Literal["auto", "modern", "legacy"]
+_VALID = ("auto", "modern", "legacy")
+
+
+class IpWare:
+ """Best-effort client IP resolver with a pluggable algorithm."""
+
+ def __init__(
+ self,
+ precedence: Optional[tuple[str, ...]] = None,
+ leftmost: bool = True,
+ proxy_count: Optional[int] = None,
+ proxy_list: Optional[list[str]] = None,
+ algorithm: Algorithm = "auto",
+ ) -> None:
+ if algorithm not in _VALID:
+ msg = f"algorithm must be one of {_VALID}, got {algorithm!r}"
+ raise ValueError(msg)
+
+ self.algorithm: Algorithm = algorithm
+ # "auto" resolves to "modern": the enhanced engine is the forward-moving
+ # default. "legacy" stays available as an explicit escape hatch.
+ resolved = "modern" if algorithm == "auto" else algorithm
+ self.resolved_algorithm = resolved
+
+ if resolved == "legacy":
+ self._impl = LegacyIpWare(
+ precedence=precedence,
+ leftmost=leftmost,
+ proxy_count=proxy_count,
+ proxy_list=proxy_list,
+ )
+ else:
+ self._impl = ModernIpWare(
+ precedence=precedence,
+ leftmost=leftmost,
+ proxy_count=proxy_count,
+ proxy_list=proxy_list,
+ )
+
+ @property
+ def engine(self) -> "LegacyIpWare | ModernIpWare":
+ """The concrete engine instance selected by ``algorithm`` (read-only)."""
+ return self._impl
+
+ def get_client_ip(self, meta, strict: bool = False):
+ """Delegate to the resolved engine. Returns ``(ip, trusted_route)``."""
+ return self._impl.get_client_ip(meta, strict)
+
+ def __repr__(self) -> str:
+ return (
+ f"IpWare(algorithm={self.algorithm!r} -> "
+ f"{self.resolved_algorithm!r})"
+ )
diff --git a/tests/legacy/__init__.py b/tests/legacy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/legacy/run_against_legacy.py b/tests/legacy/run_against_legacy.py
new file mode 100644
index 0000000..e384426
--- /dev/null
+++ b/tests/legacy/run_against_legacy.py
@@ -0,0 +1,41 @@
+"""Run the untouched v3 test suite against the frozen legacy engine.
+
+The original v3 test files (``tests_ipv4.py``, ``tests_ipv6.py``) are kept
+byte-for-byte and import ``from python_ipware import IpWare`` — which now
+resolves to the *modern* engine by default. This runner rebinds that name to
+the legacy engine so the frozen algorithm is validated against its own
+original test suite, without modifying the test files.
+
+Usage::
+
+ python -m tests.legacy.run_against_legacy
+"""
+
+from __future__ import annotations
+
+import functools
+import sys
+import unittest
+
+from python_ipware import IpWare
+
+from . import tests_ipv4, tests_ipv6
+
+LegacyBound = functools.partial(IpWare, algorithm="legacy")
+
+
+def build_suite() -> unittest.TestSuite:
+ # Rebind the IpWare symbol the untouched test modules use.
+ tests_ipv4.IpWare = LegacyBound # type: ignore[attr-defined]
+ tests_ipv6.IpWare = LegacyBound # type: ignore[attr-defined]
+
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+ suite.addTests(loader.loadTestsFromModule(tests_ipv4))
+ suite.addTests(loader.loadTestsFromModule(tests_ipv6))
+ return suite
+
+
+if __name__ == "__main__":
+ result = unittest.TextTestRunner(verbosity=1).run(build_suite())
+ sys.exit(0 if result.wasSuccessful() else 1)
diff --git a/tests/tests_ipv4.py b/tests/legacy/tests_ipv4.py
similarity index 100%
rename from tests/tests_ipv4.py
rename to tests/legacy/tests_ipv4.py
diff --git a/tests/tests_ipv6.py b/tests/legacy/tests_ipv6.py
similarity index 100%
rename from tests/tests_ipv6.py
rename to tests/legacy/tests_ipv6.py
diff --git a/tests/tests_router.py b/tests/tests_router.py
new file mode 100644
index 0000000..b0d4c85
--- /dev/null
+++ b/tests/tests_router.py
@@ -0,0 +1,213 @@
+import ipaddress
+import logging
+import unittest
+from typing import ClassVar
+
+from python_ipware import IpWare, LegacyIpWare, ModernIpWare
+
+logging.disable(logging.CRITICAL)
+
+
+class TestAlgorithmRouter(unittest.TestCase):
+ def test_default_is_auto_modern(self):
+ ipw = IpWare()
+ self.assertEqual(ipw.algorithm, "auto")
+ self.assertEqual(ipw.resolved_algorithm, "modern")
+ self.assertIsInstance(ipw.engine, ModernIpWare)
+
+ def test_explicit_modern(self):
+ self.assertIsInstance(IpWare(algorithm="modern").engine, ModernIpWare)
+
+ def test_explicit_legacy(self):
+ ipw = IpWare(algorithm="legacy")
+ self.assertEqual(ipw.resolved_algorithm, "legacy")
+ self.assertIsInstance(ipw.engine, LegacyIpWare)
+
+ def test_invalid_algorithm(self):
+ with self.assertRaises(ValueError):
+ IpWare(algorithm="bogus")
+
+ def test_all_algorithms_agree_on_simple_chain(self):
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 198.84.193.157, 198.84.193.158"}
+ results = {
+ algo: str(IpWare(algorithm=algo).get_client_ip(meta)[0])
+ for algo in ("auto", "modern", "legacy")
+ }
+ self.assertEqual(set(results.values()), {"177.139.233.139"})
+
+
+class TestModernNewHeaders(unittest.TestCase):
+ def test_true_client_ip(self):
+ ip, _ = IpWare().get_client_ip({"HTTP_TRUE_CLIENT_IP": "203.0.113.10"})
+ self.assertEqual(str(ip), "203.0.113.10")
+
+ def test_fastly_client_ip(self):
+ ip, _ = IpWare().get_client_ip({"HTTP_FASTLY_CLIENT_IP": "203.0.113.11"})
+ self.assertEqual(str(ip), "203.0.113.11")
+
+ def test_appengine_user_ip(self):
+ ip, _ = IpWare().get_client_ip({"HTTP_X_APPENGINE_USER_IP": "203.0.113.12"})
+ self.assertEqual(str(ip), "203.0.113.12")
+
+
+class TestModernHardening(unittest.TestCase):
+ def test_quoted_token(self):
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": '"8.8.8.8"'})
+ self.assertEqual(str(ip), "8.8.8.8")
+
+ def test_ipv4_mapped_unwrapped(self):
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": "::ffff:8.8.8.8"})
+ self.assertEqual(str(ip), "8.8.8.8")
+
+ def test_bracketed_ipv6_with_port(self):
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": "[2001:db8::1]:443"})
+ self.assertEqual(str(ip), "2001:db8::1")
+
+
+class TestModernFlyHeader(unittest.TestCase):
+ """Fly.io support, suggested by @mdalp in #23."""
+
+ def test_fly_client_ip_django_style(self):
+ ip, _ = IpWare().get_client_ip({"HTTP_FLY_CLIENT_IP": "203.0.113.13"})
+ self.assertEqual(str(ip), "203.0.113.13")
+
+ def test_fly_client_ip_raw_header(self):
+ ip, _ = IpWare().get_client_ip({"FLY-CLIENT-IP": "203.0.113.14"})
+ self.assertEqual(str(ip), "203.0.113.14")
+
+
+class TestModernCidrProxyList(unittest.TestCase):
+ """CIDR entries in proxy_list, requested by @griffi-gh in #26."""
+
+ def test_ipv4_cidr_trusted(self):
+ ipw = IpWare(proxy_list=["100.64.0.0/10"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 100.100.1.2"}
+ self.assertEqual(ipw.get_client_ip(meta), (ipaddress.ip_address("177.139.233.139"), True))
+
+ def test_ipv4_cidr_outside_rejected(self):
+ ipw = IpWare(proxy_list=["100.64.0.0/10"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 100.128.0.1"}
+ self.assertEqual(ipw.get_client_ip(meta), (None, False))
+
+ def test_cidr_is_not_a_string_prefix(self):
+ # "10.1.0.0/16" must not match 10.10.x.x the way the prefix "10.1" would.
+ ipw = IpWare(proxy_list=["10.1.0.0/16"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 10.10.0.1"}
+ self.assertEqual(ipw.get_client_ip(meta), (None, False))
+
+ def test_ipv6_cidr_trusted(self):
+ ipw = IpWare(proxy_list=["fd7a:115c:a1e0::/48"])
+ meta = {"HTTP_X_FORWARDED_FOR": "2606:4700::1, fd7a:115c:a1e0:ab12::1"}
+ ip, trusted = ipw.get_client_ip(meta)
+ self.assertEqual(str(ip), "2606:4700::1")
+ self.assertTrue(trusted)
+
+ def test_cross_version_never_matches(self):
+ ipw = IpWare(proxy_list=["fd7a:115c:a1e0::/48"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 100.100.1.2"}
+ self.assertEqual(ipw.get_client_ip(meta), (None, False))
+
+ def test_mixed_cidr_and_prefix(self):
+ ipw = IpWare(proxy_list=["198.84.", "100.64.0.0/10"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 198.84.193.157, 100.100.1.2"}
+ ip, trusted = ipw.get_client_ip(meta, strict=True)
+ self.assertEqual(str(ip), "177.139.233.139")
+ self.assertTrue(trusted)
+
+ def test_invalid_cidr_raises(self):
+ with self.assertRaises(ValueError):
+ IpWare(proxy_list=["300.1.0.0/16"])
+
+ def test_legacy_unchanged(self):
+ # Legacy is frozen: CIDR text is still treated as a literal prefix there.
+ ipw = IpWare(algorithm="legacy", proxy_list=["100.64.0.0/10"])
+ meta = {"HTTP_X_FORWARDED_FOR": "177.139.233.139, 100.100.1.2"}
+ self.assertEqual(ipw.get_client_ip(meta), (None, False))
+
+
+class TestModernRightmost(unittest.TestCase):
+ """leftmost=False must validate proxies and pick the client from the same end."""
+
+ XFF: ClassVar[dict[str, str]] = {
+ "HTTP_X_FORWARDED_FOR": "198.84.193.158, 198.84.193.157, 177.139.233.139"
+ }
+
+ def test_rightmost_proxy_list_exact(self):
+ ipw = IpWare(leftmost=False, proxy_list=["198.84.193.157", "198.84.193.158"])
+ ip, trusted = ipw.get_client_ip(self.XFF, strict=True)
+ self.assertEqual(str(ip), "177.139.233.139")
+ self.assertTrue(trusted)
+
+ def test_rightmost_proxy_list_prefix(self):
+ ipw = IpWare(leftmost=False, proxy_list=["198.84"])
+ ip, trusted = ipw.get_client_ip(self.XFF)
+ self.assertEqual(str(ip), "198.84.193.157")
+ self.assertTrue(trusted)
+
+ def test_rightmost_untrusted_proxy_rejected(self):
+ ipw = IpWare(leftmost=False, proxy_list=["10.0.0."])
+ self.assertEqual(ipw.get_client_ip(self.XFF), (None, False))
+
+
+class TestModernStrict(unittest.TestCase):
+ def test_strict_rejects_empty_token(self):
+ meta = {"HTTP_X_FORWARDED_FOR": "1.2.3.4,, 5.6.7.8"}
+ self.assertEqual(IpWare().get_client_ip(meta, strict=True), (None, False))
+
+ def test_non_strict_skips_empty_token(self):
+ meta = {"HTTP_X_FORWARDED_FOR": "1.2.3.4,, 5.6.7.8"}
+ ip, _ = IpWare().get_client_ip(meta)
+ self.assertEqual(str(ip), "1.2.3.4")
+
+
+class TestModernMatchesLegacy(unittest.TestCase):
+ """Differential check: on well-formed input, modern must agree with legacy
+ for every combination of direction, proxy_count, proxy_list and strict."""
+
+ CHAINS = (
+ "177.139.233.139",
+ "177.139.233.139, 198.84.193.157",
+ "177.139.233.139, 198.84.193.157, 198.84.193.158",
+ "198.84.193.158, 198.84.193.157, 177.139.233.139",
+ "10.0.0.1, 177.139.233.139, 198.84.193.157",
+ "192.168.1.1, 10.0.0.2",
+ "127.0.0.1, 198.84.193.157",
+ "2001:db8::1, 2606:4700::1",
+ "[2606:4700::6810:84e5]:443, 198.84.193.157:8080",
+ "::ffff:177.139.233.139, 198.84.193.157",
+ "not-an-ip, 177.139.233.139, 198.84.193.157",
+ )
+ PROXY_COUNTS = (None, 0, 1, 2, 3)
+ PROXY_LISTS = (
+ None,
+ ["198.84.193.157"],
+ ["198.84.193.157", "198.84.193.158"],
+ ["198.84.193.158", "198.84.193.157"],
+ ["198.84"],
+ ["10.0.0."],
+ ["177.139.233.139"],
+ )
+
+ def test_engines_agree(self):
+ checked = 0
+ for raw in self.CHAINS:
+ for header in ("HTTP_X_FORWARDED_FOR", "REMOTE_ADDR"):
+ meta = {header: raw}
+ for leftmost in (True, False):
+ for count in self.PROXY_COUNTS:
+ for plist in self.PROXY_LISTS:
+ kw = {"leftmost": leftmost, "proxy_count": count, "proxy_list": plist}
+ legacy = IpWare(algorithm="legacy", **kw)
+ modern = IpWare(algorithm="modern", **kw)
+ for strict in (False, True):
+ with self.subTest(raw=raw, header=header, strict=strict, **kw):
+ self.assertEqual(
+ modern.get_client_ip(meta, strict),
+ legacy.get_client_ip(meta, strict),
+ )
+ checked += 1
+ self.assertGreater(checked, 3000)
+
+
+if __name__ == "__main__":
+ unittest.main()