From 8051b80b29b55f52529c73a5964f78b1777c3546 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 12:49:44 -0400 Subject: [PATCH 01/11] Release 4.0.0: modern engine by default, legacy frozen - Add an algorithm router: IpWare(algorithm="auto"|"modern"|"legacy"). "auto" resolves to "modern"; "legacy" is available only when requested. - Move the v3 algorithm to python_ipware/legacy/ unchanged (byte-identical), and the v3 tests to tests/legacy/ unchanged, with a runner that checks them against the legacy engine. - Add the modern engine: hardened IPv6, bracketed-port and IPv4-mapped parsing, and an expanded default header list. The full v3 suite passes against it. - Modernize packaging: PEP 621 pyproject with Hatchling, SPDX license, Python 3.9-3.13, ruff lint.* config. - Fix CI: current action versions, supported Pythons, legacy suite step, twine check. - Rewrite README around the modern engine with Mermaid diagrams, Maintained with Dojo badge and sponsors; move the v3 guide to the legacy folder. --- .github/workflows/ci.yml | 55 ++- CHANGELOG.md | 20 + README.md | 412 ++++++++---------- pyproject.toml | 90 ++-- python_ipware/__init__.py | 8 +- python_ipware/__version__.py | 2 +- python_ipware/legacy/README.md | 326 ++++++++++++++ python_ipware/legacy/__init__.py | 12 + .../{python_ipware.py => legacy/engine.py} | 0 python_ipware/modern/__init__.py | 5 + python_ipware/modern/defaults.py | 32 ++ python_ipware/modern/engine.py | 127 ++++++ python_ipware/modern/parsers.py | 75 ++++ python_ipware/router.py | 80 ++++ tests/legacy/__init__.py | 0 tests/legacy/run_against_legacy.py | 41 ++ tests/{ => legacy}/tests_ipv4.py | 0 tests/{ => legacy}/tests_ipv6.py | 0 tests/tests_router.py | 66 +++ 19 files changed, 1065 insertions(+), 286 deletions(-) create mode 100644 python_ipware/legacy/README.md create mode 100644 python_ipware/legacy/__init__.py rename python_ipware/{python_ipware.py => legacy/engine.py} (100%) create mode 100644 python_ipware/modern/__init__.py create mode 100644 python_ipware/modern/defaults.py create mode 100644 python_ipware/modern/engine.py create mode 100644 python_ipware/modern/parsers.py create mode 100644 python_ipware/router.py create mode 100644 tests/legacy/__init__.py create mode 100644 tests/legacy/run_against_legacy.py rename tests/{ => legacy}/tests_ipv4.py (100%) rename tests/{ => legacy}/tests_ipv6.py (100%) create mode 100644 tests/tests_router.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b91cbd5..082f4c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,51 +13,70 @@ 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", "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" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e .[dev] + pip install -e ".[dev]" 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 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 + 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" - 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 +88,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..4f8c968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 4.0.0 + +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; supported range is now 3.9–3.13. +- 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, which is a verified superset of v3 behavior. + ## 3.0.0 Fix: diff --git a/README.md b/README.md index b218bc2..e07d4e7 100644 --- a/README.md +++ b/README.md @@ -1,313 +1,259 @@ -# 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 – 3.13 is supported. No runtime dependencies. -| 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(request.META)"] + 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 + +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 request.META 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 proxy 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 proxy prefixes, e.g. `["10.1.", "198.84.193.157"]`, matched against the proxies nearest your server. | +| `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.META"] --> 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_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", + "FORWARDED", + "CLIENT-IP", + "REMOTE_ADDR", # direct connection +) ``` -### 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. +Narrow it to what your infrastructure actually sets: ```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"]) - +ipw = IpWare(precedence=("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 +# 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/pyproject.toml b/pyproject.toml index ad4c5e9..ab58f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,64 +1,90 @@ [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", + "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] dev = [ - "ruff", - "coveralls~=3.3", - "coverage[toml]", - "twine" + "ruff", + "coverage[toml]", + "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", + "LICENSE", +] [tool.ruff] +line-length = 120 +target-version = "py39" + +[tool.ruff.lint] select = [ "B", "C4", "C9", - "DJ", "E", "EM", "F", @@ -78,13 +104,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..bfa4d35 --- /dev/null +++ b/python_ipware/modern/defaults.py @@ -0,0 +1,32 @@ +"""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_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", + "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..1acd009 --- /dev/null +++ b/python_ipware/modern/engine.py @@ -0,0 +1,127 @@ +"""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 by prefix, anchored to the end of the chain. +""" + +from typing import Optional + +from .defaults import DEFAULT_PRECEDENCE +from .parsers import IpAddressType, split_proxy_chain + +OptionalIp = Optional[IpAddressType] + + +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 []) + + # -- 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 prefixes in order. + return all( + str(ip).startswith(pattern) + for ip, pattern in zip(chain[-count:], self.proxy_list) + ) + + # -- selection ---------------------------------------------------------- + + def _best_from_chain(self, chain: list[IpAddressType]) -> tuple[OptionalIp, bool]: + if not chain: + return None, False + + # Order the chain client-first regardless of leftmost, so indexing is + # consistent. XFF is naturally client, proxy1, proxy2 ... + ordered = chain if self.leftmost else list(reversed(chain)) + + if self.proxy_list: + idx = len(self.proxy_list) + 1 + return ordered[-idx], True + if self.proxy_count is not None: + idx = self.proxy_count + 1 + return ordered[-idx], True + return ordered[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 + 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..ece3256 --- /dev/null +++ b/python_ipware/modern/parsers.py @@ -0,0 +1,75 @@ +"""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 (``client, proxy1, proxy2``). In strict + mode, a single invalid token makes the whole chain invalid (returns None). + Otherwise invalid 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 and token.strip(): + 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..a8a49fa --- /dev/null +++ b/python_ipware/router.py @@ -0,0 +1,80 @@ +"""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, and it is a verified behavioral superset of the v3 algorithm (the full +v3 test suite passes against it). ``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..909df75 --- /dev/null +++ b/tests/tests_router.py @@ -0,0 +1,66 @@ +import logging +import unittest + +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") + + +if __name__ == "__main__": + unittest.main() From d148c5c7d2ef3bbaa4c41cefe457fc72896b1a18 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 12:53:41 -0400 Subject: [PATCH 02/11] README: keep diagrams framework-neutral (drop Django-specific request.META) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e07d4e7..4d37f1c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Python 3.9 – 3.13 is supported. No runtime dependencies. ```mermaid flowchart LR - R["Incoming request"] --> I["IpWare().get_client_ip(request.META)"] + 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"] @@ -72,7 +72,7 @@ sequenceDiagram ```python IpWare( - precedence=None, # tuple of request.META keys to check, in order + 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 proxy IP prefixes @@ -101,7 +101,7 @@ Headers are checked in precedence order. The first **public** IP found wins; oth ```mermaid flowchart TD - A["request.META"] --> B["Take the next header in precedence order"] + 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"] From 50e780ff0b0a0af06c8cfd52a33cced5be6463a0 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 12:56:51 -0400 Subject: [PATCH 03/11] Declare Python 3.9+ open-ended support; add 3.14 to classifiers and CI --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- pyproject.toml | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 082f4c7..6d7eca1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.10"] steps: - uses: actions/checkout@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8c968..914ac04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Enhance: 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; supported range is now 3.9–3.13. +- 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: diff --git a/README.md b/README.md index 4d37f1c..d3fbaba 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ if trusted_route: ... ``` -Python 3.9 – 3.13 is supported. No runtime dependencies. +Python 3.9+ is supported (tested on 3.9 – 3.14). No runtime dependencies. > **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). diff --git a/pyproject.toml b/pyproject.toml index ab58f6f..8aedd60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ classifiers = [ "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", From 9b0706381792e73af2e8d8ce25c5dd35125311f6 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 12:57:32 -0400 Subject: [PATCH 04/11] README: note no upper Python cap and invite reports on newer versions --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d3fbaba..1a91eec 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ if trusted_route: ... ``` -Python 3.9+ is supported (tested on 3.9 – 3.14). No runtime dependencies. +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. > **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). From caa0e779a58ca29e8ee30da165ce3d551d82d4af Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 12:58:28 -0400 Subject: [PATCH 05/11] Add SECURITY.md: report vulnerabilities privately by email, not public issues or PRs --- README.md | 3 +++ SECURITY.md | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 1a91eec..89a2271 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ flowchart LR ## 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 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. From cc8e673702c046350573b45e5028bc7fc08aa1e2 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 13:01:19 -0400 Subject: [PATCH 06/11] note --- RELEASE_NOTES_v4.0.0.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 RELEASE_NOTES_v4.0.0.md diff --git a/RELEASE_NOTES_v4.0.0.md b/RELEASE_NOTES_v4.0.0.md new file mode 100644 index 0000000..748dc00 --- /dev/null +++ b/RELEASE_NOTES_v4.0.0.md @@ -0,0 +1,41 @@ +# 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 and is a verified behavioral +superset of the v3 algorithm — the full v3 test suite passes against it. 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) ⛩️ From 7b4c058ca2eb5fc8ab7aa753707ae1f13a57ecba Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 13:08:02 -0400 Subject: [PATCH 07/11] Fix modern right-most proxy_list bug; review fixes - Modern engine: with leftmost=False, proxy_list validated the raw chain but picked the client from the reversed chain, so right-most networks with a trusted proxy list returned None. Put the chain in client-first order once, before validation, matching legacy. - Strict mode now rejects empty entries (e.g. "1.2.3.4,, 5.6.7.8"), as v3 does. - Tests: right-most + proxy_list regressions, strict empty-token tests, and a legacy-vs-modern differential test over 3,000+ well-formed combinations. These fail 211 times against the previous engine. - Docs: replace "verified superset of v3" with the accurate claim and list the two remaining malformed-input differences (quoted addresses accepted, 1.2.3.4:80:90 rejected). - CI: key the pip cache on pyproject.toml (the default requirements.txt lookup would fail setup on every job); make Coveralls upload non-fatal. - Packaging: include SECURITY.md in the sdist. --- .github/workflows/ci.yml | 5 ++ CHANGELOG.md | 6 ++- pyproject.toml | 1 + python_ipware/modern/engine.py | 18 ++++--- python_ipware/modern/parsers.py | 9 ++-- python_ipware/router.py | 5 +- tests/tests_router.py | 85 +++++++++++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d7eca1..64d76bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: "pip" + cache-dependency-path: pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip @@ -42,6 +43,8 @@ jobs: - 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 }} @@ -57,6 +60,7 @@ jobs: uses: coverallsapp/github-action@v2 with: parallel-finished: true + fail-on-error: false build: name: Build @@ -70,6 +74,7 @@ jobs: with: python-version: "3.12" cache: "pip" + cache-dependency-path: pyproject.toml - name: Install build tools run: python -m pip install --upgrade build twine - name: Build diff --git a/CHANGELOG.md b/CHANGELOG.md index 914ac04..b68db9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ Modernize: Note: - No source change is required for existing users: `from python_ipware import IpWare` continues to work and - now defaults to the modern engine, which is a verified superset of v3 behavior. + 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 diff --git a/pyproject.toml b/pyproject.toml index 8aedd60..6119cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ include = [ "tests", "README.md", "CHANGELOG.md", + "SECURITY.md", "LICENSE", ] diff --git a/python_ipware/modern/engine.py b/python_ipware/modern/engine.py index 1acd009..5129e00 100644 --- a/python_ipware/modern/engine.py +++ b/python_ipware/modern/engine.py @@ -78,20 +78,14 @@ def _proxy_list_valid(self, chain: list[IpAddressType], strict: bool) -> bool: # -- 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 - - # Order the chain client-first regardless of leftmost, so indexing is - # consistent. XFF is naturally client, proxy1, proxy2 ... - ordered = chain if self.leftmost else list(reversed(chain)) - if self.proxy_list: - idx = len(self.proxy_list) + 1 - return ordered[-idx], True + return chain[-(len(self.proxy_list) + 1)], True if self.proxy_count is not None: - idx = self.proxy_count + 1 - return ordered[-idx], True - return ordered[0], False + return chain[-(self.proxy_count + 1)], True + return chain[0], False # -- public API --------------------------------------------------------- @@ -105,6 +99,10 @@ def get_client_ip( 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): diff --git a/python_ipware/modern/parsers.py b/python_ipware/modern/parsers.py index ece3256..e1cbf92 100644 --- a/python_ipware/modern/parsers.py +++ b/python_ipware/modern/parsers.py @@ -55,9 +55,10 @@ def parse_ip(value: Optional[str]) -> Optional[IpAddressType]: 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 (``client, proxy1, proxy2``). In strict - mode, a single invalid token makes the whole chain invalid (returns None). - Otherwise invalid tokens are skipped. + 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 [] @@ -66,7 +67,7 @@ def split_proxy_chain(raw: Optional[str], strict: bool = False) -> Optional[list ip = parse_ip(token) if ip is not None: result.append(ip) - elif strict and token.strip(): + elif strict: return None return result diff --git a/python_ipware/router.py b/python_ipware/router.py index a8a49fa..d733371 100644 --- a/python_ipware/router.py +++ b/python_ipware/router.py @@ -7,8 +7,9 @@ The ``algorithm`` selector chooses between them. ``"auto"`` (the default) is a clean alias for ``"modern"`` — the enhanced engine is where development moves -forward, and it is a verified behavioral superset of the v3 algorithm (the full -v3 test suite passes against it). ``legacy`` remains available as an explicit +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. diff --git a/tests/tests_router.py b/tests/tests_router.py index 909df75..bf65ed5 100644 --- a/tests/tests_router.py +++ b/tests/tests_router.py @@ -1,5 +1,6 @@ import logging import unittest +from typing import ClassVar from python_ipware import IpWare, LegacyIpWare, ModernIpWare @@ -62,5 +63,89 @@ def test_bracketed_ipv6_with_port(self): self.assertEqual(str(ip), "2001:db8::1") +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() From 8d42b9dbb5c5f9ec3a33d0bc95b1b79c404875ca Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 13:08:21 -0400 Subject: [PATCH 08/11] Release notes: accurate modern-vs-v3 compatibility wording --- RELEASE_NOTES_v4.0.0.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES_v4.0.0.md b/RELEASE_NOTES_v4.0.0.md index 748dc00..68c9cf5 100644 --- a/RELEASE_NOTES_v4.0.0.md +++ b/RELEASE_NOTES_v4.0.0.md @@ -4,9 +4,11 @@ > 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 and is a verified behavioral -superset of the v3 algorithm — the full v3 test suite passes against it. The -**legacy** v3 algorithm is preserved byte-for-byte as an explicit escape hatch. +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 From 9d48ecf48e19c75bf8285e178c964187b7d68416 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 14:53:34 -0400 Subject: [PATCH 09/11] Community: CIDR proxy_list (#26), Fly.io header (#23), CDN precedence docs (#25) - Modern engine: proxy_list entries may be CIDR networks (IPv4/IPv6), matched by real network membership; plain prefixes still work. Invalid CIDR raises ValueError. Legacy unchanged. Requested by @griffi-gh in #26. - Modern defaults: add Fly.io's Fly-Client-IP header. Suggested by @mdalp in #23. - README: document CIDR entries and how to put a CDN header first via precedence when all traffic comes through that CDN. Suggested by @iloveitaly in #25. - CHANGELOG: credit community contributions, including @iloveitaly's Python 3.13 CI suggestion (#24). Co-authored-by: mdalp Co-authored-by: iloveitaly Co-authored-by: griffi-gh --- CHANGELOG.md | 8 +++++ README.md | 15 ++++++-- python_ipware/modern/defaults.py | 2 ++ python_ipware/modern/engine.py | 35 +++++++++++++++--- tests/tests_router.py | 62 ++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b68db9f..9a95130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## 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. diff --git a/README.md b/README.md index 89a2271..aecbee9 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ 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 proxy IP prefixes + proxy_list=None, # trusted proxies: CIDR networks or IP prefixes ) ip, trusted_route = ipw.get_client_ip(meta, strict=False) @@ -91,7 +91,7 @@ ip, trusted_route = ipw.get_client_ip(meta, strict=False) | `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 proxy prefixes, e.g. `["10.1.", "198.84.193.157"]`, matched against the proxies nearest your server. | +| `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 | @@ -138,6 +138,7 @@ Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`) and IPv4-mapped IPv6 ad "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 @@ -147,6 +148,7 @@ Ports are stripped (`1.2.3.4:8080`, `[2001:db8::1]:443`) and IPv4-mapped IPv6 ad "CF-CONNECTING-IP", "TRUE-CLIENT-IP", "FASTLY-CLIENT-IP", + "FLY-CLIENT-IP", "FORWARDED", "CLIENT-IP", "REMOTE_ADDR", # direct connection @@ -159,6 +161,14 @@ Narrow it to what your infrastructure actually sets: ipw = IpWare(precedence=("HTTP_X_FORWARDED_FOR", "REMOTE_ADDR")) ``` +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 +# Behind Cloudflare only +ipw = IpWare(precedence=("HTTP_CF_CONNECTING_IP", "HTTP_X_FORWARDED_FOR", "REMOTE_ADDR")) +``` + ## Trusted proxies If your server sits behind known proxies, pass their IPs or prefixes: @@ -167,6 +177,7 @@ If your server sits behind known proxies, pass their IPs or prefixes: 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) diff --git a/python_ipware/modern/defaults.py b/python_ipware/modern/defaults.py index bfa4d35..fe51141 100644 --- a/python_ipware/modern/defaults.py +++ b/python_ipware/modern/defaults.py @@ -17,6 +17,7 @@ "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 @@ -26,6 +27,7 @@ "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 index 5129e00..5f8fbcd 100644 --- a/python_ipware/modern/engine.py +++ b/python_ipware/modern/engine.py @@ -8,15 +8,39 @@ * 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 by prefix, anchored to the end of the chain. +* 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."``). """ -from typing import Optional +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: @@ -36,6 +60,7 @@ def __init__( 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 -------------------------------------------------------- @@ -69,10 +94,10 @@ def _proxy_list_valid(self, chain: list[IpAddressType], strict: bool) -> bool: return False if (len(chain) - 1) < count: return False - # Compare the trailing proxies against the trusted prefixes in order. + # Compare the trailing proxies against the trusted entries in order. return all( - str(ip).startswith(pattern) - for ip, pattern in zip(chain[-count:], self.proxy_list) + _proxy_matches(ip, matcher) + for ip, matcher in zip(chain[-count:], self._proxy_matchers) ) # -- selection ---------------------------------------------------------- diff --git a/tests/tests_router.py b/tests/tests_router.py index bf65ed5..b0d4c85 100644 --- a/tests/tests_router.py +++ b/tests/tests_router.py @@ -1,3 +1,4 @@ +import ipaddress import logging import unittest from typing import ClassVar @@ -63,6 +64,67 @@ def test_bracketed_ipv6_with_port(self): 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.""" From 540da2ffc3b1e479cff459fe2fcb263cf6cd3624 Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 14:58:13 -0400 Subject: [PATCH 10/11] Lint: exclude frozen legacy code from ruff; pin ruff 0.16.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI installed ruff 0.16.8, whose new PLC0207 rule flagged two lines in the frozen v3 engine. Legacy must stay byte-identical, so exclude it (and its v3 tests) from linting instead of editing it, and pin ruff so future rule additions cannot break CI unannounced. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6119cfa..4f79d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ Changelog = "https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md" [project.optional-dependencies] dev = [ - "ruff", + "ruff==0.16.8", # pinned so new lint rules cannot break CI unannounced "coverage[toml]", "build", "twine", @@ -81,6 +81,9 @@ include = [ [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 = [ From 961df440ff878921dc697aa30e94310f3c25a2bf Mon Sep 17 00:00:00 2001 From: Val Neekman Date: Tue, 22 Sep 2026 15:01:09 -0400 Subject: [PATCH 11/11] CI: install a lean [test] extra so PyPy can build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit twine (in [dev]) pulls nh3/cryptography, which need a Rust build on PyPy and failed there. Test jobs only need ruff and coverage, so add a [test] extra and use it in CI; [dev] now extends [test] with build and twine. 🚀 Generated with [Dojo](https://heydojo.ai) ⛩️ --- .github/workflows/ci.yml | 2 +- pyproject.toml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64d76bf..fb67e56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev]" coveralls + pip install -e ".[test]" coveralls - name: Run ruff run: ruff check . - name: Run tests (modern default + router) diff --git a/pyproject.toml b/pyproject.toml index 4f79d1f..d65566c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,9 +55,14 @@ Issues = "https://github.com/un33k/python-ipware/issues" Changelog = "https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md" [project.optional-dependencies] -dev = [ +# 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 = [ + "python-ipware[test]", "build", "twine", ]