fastapi-ipware 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,26 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastapi-ipware
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses
5
5
  Keywords: fastapi,starlette,ipware,ip-address,client-ip,proxy,x-forwarded-for
6
6
  Author: Michael Bianco
7
7
  Author-email: Michael Bianco <mike@mikebian.co>
8
- Requires-Dist: python-ipware>=3.0.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: python-ipware>=4.1.0
9
14
  Requires-Dist: starlette>=0.49.1
10
- Requires-Python: >=3.10
15
+ Requires-Python: >=3.12
11
16
  Project-URL: Repository, https://github.com/iloveitaly/fastapi-ipware
12
17
  Description-Content-Type: text/markdown
13
18
 
19
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/fastapi-ipware)](https://github.com/iloveitaly/fastapi-ipware/releases)
20
+ [![Downloads](https://static.pepy.tech/badge/fastapi-ipware/month)](https://pepy.tech/project/fastapi-ipware)
21
+ ![GitHub CI Status](https://github.com/iloveitaly/fastapi-ipware/actions/workflows/build_and_publish.yml/badge.svg)
22
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
23
+
14
24
  # fastapi-ipware
15
25
 
16
26
  A FastAPI/Starlette-native wrapper for [python-ipware](https://github.com/un33k/python-ipware) that eliminates the need for WSGI-style header conversion.
@@ -35,7 +45,38 @@ uv add fastapi-ipware
35
45
 
36
46
  ## Quick Start
37
47
 
38
- [Take a look at this example usage.](https://github.com/iloveitaly/structlog-config/blob/1e32f7afd206485b1356b322596cc19cb0bef8ba/structlog_config/fastapi_access_logger.py#L57-L72)
48
+ `trusted` is true only when the request came through the proxies configured with `proxy_count` or `proxy_list`.
49
+
50
+ `proxy_count=N` returns the address just left of the N rightmost proxies, instead of the first public address, and rejects a shorter chain. `X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2` returns `203.0.113.10` with no `proxy_count`, and `10.0.0.1` with `proxy_count=1`.
51
+
52
+ ### Using FastAPI Dependency Injection
53
+
54
+ ```python
55
+ from typing import Annotated
56
+ from fastapi import Depends, FastAPI
57
+ from fastapi_ipware import ClientIpResult, FastAPIIpWare
58
+
59
+ app = FastAPI()
60
+ ipware = FastAPIIpWare()
61
+
62
+
63
+ @app.get("/")
64
+ async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
65
+ ip, trusted = client
66
+ return {
67
+ "ip": str(ip) if ip else None,
68
+ "trusted": trusted,
69
+ "is_public": ip.is_global if ip else None,
70
+ }
71
+
72
+
73
+ # Or inject only the IP object or string directly:
74
+ @app.get("/ip-string")
75
+ async def get_ip_string(ip_str: Annotated[str | None, Depends(ipware.get_ip_str)]):
76
+ return {"ip": ip_str}
77
+ ```
78
+
79
+ ### Using Request Directly
39
80
 
40
81
  ```python
41
82
  from fastapi import FastAPI, Request
@@ -44,32 +85,40 @@ from fastapi_ipware import FastAPIIpWare
44
85
  app = FastAPI()
45
86
  ipware = FastAPIIpWare()
46
87
 
88
+
47
89
  @app.get("/")
48
90
  async def get_ip(request: Request):
49
91
  ip, trusted = ipware.get_client_ip_from_request(request)
50
-
51
- if ip:
52
- return {
53
- "ip": str(ip),
54
- "trusted": trusted,
55
- "is_public": ip.is_global,
56
- "is_private": ip.is_private,
57
- }
58
-
59
- return {"error": "Could not determine IP"}
92
+ return {"ip": str(ip) if ip else None, "trusted": trusted}
60
93
  ```
61
94
 
62
- ## Usage
95
+ ### Using ASGI Middleware
63
96
 
64
- ### Basic Usage
97
+ Automatically extract the client IP onto `request.state` for every request:
65
98
 
66
99
  ```python
67
- from fastapi_ipware import FastAPIIpWare
100
+ from fastapi import FastAPI, Request
101
+ from fastapi_ipware import IpWareMiddleware
68
102
 
69
- # Use default configuration (optimized for FastAPI/cloud deployments)
70
- ipware = FastAPIIpWare()
103
+ app = FastAPI()
104
+ app.add_middleware(IpWareMiddleware)
71
105
 
72
- ip, trusted = ipware.get_client_ip_from_request(request)
106
+
107
+ @app.get("/")
108
+ async def get_ip(request: Request):
109
+ return {
110
+ "ip": request.state.client_ip_str,
111
+ "trusted": request.state.ip_trusted,
112
+ }
113
+ ```
114
+
115
+ Pass a configured resolver when you need one. Omit `strict` to use that resolver's `default_strict`; pass `strict=` to override it.
116
+
117
+ ```python
118
+ from fastapi_ipware import FastAPIIpWare, IpWareMiddleware
119
+
120
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
121
+ app.add_middleware(IpWareMiddleware, ipware=ipware)
73
122
  ```
74
123
 
75
124
  ### Custom Header Precedence
@@ -100,8 +149,9 @@ ipware = FastAPIIpWare(
100
149
  Validate that requests pass through the expected number of proxies:
101
150
 
102
151
  ```python
103
- # Expect exactly 1 proxy (e.g., AWS ALB)
104
- ipware = FastAPIIpWare(proxy_count=1)
152
+ # Expect exactly 1 proxy (e.g., AWS ALB).
153
+ # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
154
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
105
155
 
106
156
  # In strict mode, must be exactly 1 proxy
107
157
  ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
@@ -112,12 +162,12 @@ ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
112
162
 
113
163
  ### Trusted Proxy List
114
164
 
115
- Validate that requests pass through specific trusted proxies:
165
+ Validate that requests pass through specific trusted proxies (supports IP prefixes, exact IPs, and CIDR networks):
116
166
 
117
167
  ```python
118
- # Trust specific proxy IP prefixes
168
+ # Trust specific proxy IP prefixes or CIDR networks
119
169
  ipware = FastAPIIpWare(
120
- proxy_list=["10.0.", "10.1."] # AWS internal IPs
170
+ proxy_list=["10.0.", "10.1.", "100.64.0.0/10"] # AWS internal IPs and CGNAT CIDR
121
171
  )
122
172
 
123
173
  ip, trusted = ipware.get_client_ip_from_request(request)
@@ -131,10 +181,19 @@ Use both proxy count and trusted proxy list:
131
181
 
132
182
  ```python
133
183
  # Expect 1 proxy from a specific IP range
134
- ipware = FastAPIIpWare(
135
- proxy_count=1,
136
- proxy_list=["10.0."]
137
- )
184
+ ipware = FastAPIIpWare(proxy_count=1, proxy_list=["10.0."])
185
+ ```
186
+
187
+ ### Algorithm Engine Selection
188
+
189
+ Choose between python-ipware 4.x engines (`auto` / `modern` / `legacy`):
190
+
191
+ ```python
192
+ # Modern engine (default): enhanced header parsing and RFC 7239 support
193
+ ipware = FastAPIIpWare(algorithm="modern")
194
+
195
+ # Legacy engine: frozen byte-for-byte v3 behavior
196
+ ipware = FastAPIIpWare(algorithm="legacy")
138
197
  ```
139
198
 
140
199
  ## IP Address Types
@@ -165,3 +224,7 @@ python-ipware automatically prefers:
165
224
 
166
225
  - Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
167
226
  - https://github.com/long2ice/fastapi-limiter/blob/8d179c058fa2aaf98f3450c9026a7300ae2b3bdd/fastapi_limiter/__init__.py#L11
227
+
228
+ ---
229
+
230
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,212 @@
1
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/fastapi-ipware)](https://github.com/iloveitaly/fastapi-ipware/releases)
2
+ [![Downloads](https://static.pepy.tech/badge/fastapi-ipware/month)](https://pepy.tech/project/fastapi-ipware)
3
+ ![GitHub CI Status](https://github.com/iloveitaly/fastapi-ipware/actions/workflows/build_and_publish.yml/badge.svg)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ # fastapi-ipware
7
+
8
+ A FastAPI/Starlette-native wrapper for [python-ipware](https://github.com/un33k/python-ipware) that eliminates the need for WSGI-style header conversion.
9
+
10
+ `python-ipware` expects WSGI-style headers (`HTTP_X_FORWARDED_FOR`), but FastAPI uses natural header names (`X-Forwarded-For`). This wrapper handles the conversion automatically so you don't have to.
11
+
12
+ Also, the default precedence order is optimized for modern cloud deployments. See the [default precedence configuration](https://github.com/iloveitaly/fastapi-ipware/blob/main/fastapi_ipware/__init__.py#L48-L58) in the source code. This is different from the
13
+ default ordering ipware which deprioritizes platform-specific headers, which is often the wrong ordering if you are using something like CloudFlare.
14
+
15
+ ## Features
16
+
17
+ - **Zero conversion overhead** - Headers converted once at initialization, not on every request
18
+ - **FastAPI-native API** - Works directly with FastAPI/Starlette `Request` objects
19
+ - **Customizable precedence** - Easy to configure header priority for your infrastructure
20
+ - **Proxy validation** - Supports trusted proxy lists and proxy count validation
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ uv add fastapi-ipware
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ `trusted` is true only when the request came through the proxies configured with `proxy_count` or `proxy_list`.
31
+
32
+ `proxy_count=N` returns the address just left of the N rightmost proxies, instead of the first public address, and rejects a shorter chain. `X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2` returns `203.0.113.10` with no `proxy_count`, and `10.0.0.1` with `proxy_count=1`.
33
+
34
+ ### Using FastAPI Dependency Injection
35
+
36
+ ```python
37
+ from typing import Annotated
38
+ from fastapi import Depends, FastAPI
39
+ from fastapi_ipware import ClientIpResult, FastAPIIpWare
40
+
41
+ app = FastAPI()
42
+ ipware = FastAPIIpWare()
43
+
44
+
45
+ @app.get("/")
46
+ async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
47
+ ip, trusted = client
48
+ return {
49
+ "ip": str(ip) if ip else None,
50
+ "trusted": trusted,
51
+ "is_public": ip.is_global if ip else None,
52
+ }
53
+
54
+
55
+ # Or inject only the IP object or string directly:
56
+ @app.get("/ip-string")
57
+ async def get_ip_string(ip_str: Annotated[str | None, Depends(ipware.get_ip_str)]):
58
+ return {"ip": ip_str}
59
+ ```
60
+
61
+ ### Using Request Directly
62
+
63
+ ```python
64
+ from fastapi import FastAPI, Request
65
+ from fastapi_ipware import FastAPIIpWare
66
+
67
+ app = FastAPI()
68
+ ipware = FastAPIIpWare()
69
+
70
+
71
+ @app.get("/")
72
+ async def get_ip(request: Request):
73
+ ip, trusted = ipware.get_client_ip_from_request(request)
74
+ return {"ip": str(ip) if ip else None, "trusted": trusted}
75
+ ```
76
+
77
+ ### Using ASGI Middleware
78
+
79
+ Automatically extract the client IP onto `request.state` for every request:
80
+
81
+ ```python
82
+ from fastapi import FastAPI, Request
83
+ from fastapi_ipware import IpWareMiddleware
84
+
85
+ app = FastAPI()
86
+ app.add_middleware(IpWareMiddleware)
87
+
88
+
89
+ @app.get("/")
90
+ async def get_ip(request: Request):
91
+ return {
92
+ "ip": request.state.client_ip_str,
93
+ "trusted": request.state.ip_trusted,
94
+ }
95
+ ```
96
+
97
+ Pass a configured resolver when you need one. Omit `strict` to use that resolver's `default_strict`; pass `strict=` to override it.
98
+
99
+ ```python
100
+ from fastapi_ipware import FastAPIIpWare, IpWareMiddleware
101
+
102
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
103
+ app.add_middleware(IpWareMiddleware, ipware=ipware)
104
+ ```
105
+
106
+ ### Custom Header Precedence
107
+
108
+ Customize which headers are checked and in what order:
109
+
110
+ ```python
111
+ # Prioritize Cloudflare headers
112
+ ipware = FastAPIIpWare(
113
+ precedence=(
114
+ "CF-Connecting-IP",
115
+ "X-Forwarded-For",
116
+ "X-Real-IP",
117
+ )
118
+ )
119
+
120
+ # NGINX configuration
121
+ ipware = FastAPIIpWare(
122
+ precedence=(
123
+ "X-Real-IP",
124
+ "X-Forwarded-For",
125
+ )
126
+ )
127
+ ```
128
+
129
+ ### Proxy Count Validation
130
+
131
+ Validate that requests pass through the expected number of proxies:
132
+
133
+ ```python
134
+ # Expect exactly 1 proxy (e.g., AWS ALB).
135
+ # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
136
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
137
+
138
+ # In strict mode, must be exactly 1 proxy
139
+ ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
140
+
141
+ # In non-strict mode, allow 1 or more proxies
142
+ ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
143
+ ```
144
+
145
+ ### Trusted Proxy List
146
+
147
+ Validate that requests pass through specific trusted proxies (supports IP prefixes, exact IPs, and CIDR networks):
148
+
149
+ ```python
150
+ # Trust specific proxy IP prefixes or CIDR networks
151
+ ipware = FastAPIIpWare(
152
+ proxy_list=["10.0.", "10.1.", "100.64.0.0/10"] # AWS internal IPs and CGNAT CIDR
153
+ )
154
+
155
+ ip, trusted = ipware.get_client_ip_from_request(request)
156
+
157
+ # trusted=True only if request came through specified proxies
158
+ ```
159
+
160
+ ### Combined Validation
161
+
162
+ Use both proxy count and trusted proxy list:
163
+
164
+ ```python
165
+ # Expect 1 proxy from a specific IP range
166
+ ipware = FastAPIIpWare(proxy_count=1, proxy_list=["10.0."])
167
+ ```
168
+
169
+ ### Algorithm Engine Selection
170
+
171
+ Choose between python-ipware 4.x engines (`auto` / `modern` / `legacy`):
172
+
173
+ ```python
174
+ # Modern engine (default): enhanced header parsing and RFC 7239 support
175
+ ipware = FastAPIIpWare(algorithm="modern")
176
+
177
+ # Legacy engine: frozen byte-for-byte v3 behavior
178
+ ipware = FastAPIIpWare(algorithm="legacy")
179
+ ```
180
+
181
+ ## IP Address Types
182
+
183
+ The returned IP address object has useful properties:
184
+
185
+ ```python
186
+ ip, _ = ipware.get_client_ip_from_request(request)
187
+
188
+ if ip:
189
+ print(f"Is public: {ip.is_global}")
190
+ print(f"Is private: {ip.is_private}")
191
+ print(f"Is loopback: {ip.is_loopback}")
192
+ print(f"Is multicast: {ip.is_multicast}")
193
+ ```
194
+
195
+ python-ipware automatically prefers:
196
+
197
+ 1. Public (global) IPs first
198
+ 2. Private IPs second
199
+ 3. Loopback IPs last
200
+
201
+ ## License
202
+
203
+ [MIT License](LICENSE.md)
204
+
205
+ ## Credits
206
+
207
+ - Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
208
+ - https://github.com/long2ice/fastapi-limiter/blob/8d179c058fa2aaf98f3450c9026a7300ae2b3bdd/fastapi_limiter/__init__.py#L11
209
+
210
+ ---
211
+
212
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,240 @@
1
+ import ipaddress
2
+ from collections.abc import Callable
3
+ from typing import Literal
4
+
5
+ from python_ipware import IpWare
6
+ from starlette.requests import HTTPConnection
7
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
+
9
+ from .version import __version__
10
+
11
+ Algorithm = Literal["auto", "modern", "legacy"]
12
+ IpAddressType = ipaddress.IPv4Address | ipaddress.IPv6Address
13
+ ClientIpResult = tuple[IpAddressType | None, bool]
14
+
15
+ DEFAULT_PRECEDENCE: tuple[str, ...] = (
16
+ # Provider-specific headers (highest reliability)
17
+ "CF-Connecting-IP", # Cloudflare
18
+ "True-Client-IP", # Cloudflare Enterprise, Akamai
19
+ "Fastly-Client-IP", # Fastly, Firebase
20
+ "Fly-Client-IP", # Fly.io
21
+ "X-Client-IP", # Microsoft Azure
22
+ "X-Azure-ClientIP", # Azure Front Door
23
+ "DO-Connecting-IP", # DigitalOcean App Platform
24
+ "X-Cluster-Client-IP", # Rackspace Cloud Load Balancers
25
+ "X-Appengine-User-IP", # Google App Engine
26
+ "X-Envoy-External-Address", # Envoy / Istio
27
+ # Generic headers (fallback)
28
+ "X-Forwarded-For", # Generic, used by AWS ELB, nginx, etc.
29
+ "X-Real-IP", # NGINX
30
+ "Forwarded-For", # Plain IP list variant
31
+ "Forwarded", # RFC 7239 (for=...;proto=...)
32
+ "Client-IP", # Akamai, Cloudflare fallback
33
+ "REMOTE_ADDR", # Direct connection fallback
34
+ )
35
+
36
+
37
+ class FastAPIIpWare(IpWare):
38
+ """
39
+ A FastAPI/Starlette-native wrapper around python-ipware that eliminates
40
+ the need for WSGI-style header conversion at request time.
41
+
42
+ This class accepts natural header names (e.g., "X-Forwarded-For") and
43
+ handles the conversion to ipware's expected format internally.
44
+
45
+ Example:
46
+ >>> from fastapi_ipware import FastAPIIpWare
47
+ >>> ipware = FastAPIIpWare()
48
+ >>> ip, trusted = ipware.get_client_ip_from_request(request)
49
+
50
+ >>> # Direct FastAPI route dependency
51
+ >>> @app.get("/")
52
+ ... def endpoint(client: ClientIpResult = Depends(ipware)):
53
+ ... ip, trusted = client
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ precedence: tuple[str, ...] | None = None,
59
+ leftmost: bool = True,
60
+ proxy_count: int | None = None,
61
+ proxy_list: list[str] | None = None,
62
+ algorithm: Algorithm = "auto",
63
+ default_strict: bool = False,
64
+ ):
65
+ """
66
+ Initialize FastAPIIpWare with optional configuration.
67
+
68
+ Args:
69
+ precedence: Tuple of header names to check in order. Uses natural header
70
+ names with dashes (e.g., "X-Forwarded-For", "X-Real-IP").
71
+ If None, uses FastAPI-optimized defaults.
72
+ leftmost: If True, use leftmost IP in comma-separated list (standard).
73
+ If False, use rightmost IP (rare legacy configurations).
74
+ proxy_count: Expected number of proxies between client and server.
75
+ Used to validate and extract the correct client IP.
76
+ proxy_list: List of trusted proxy IP prefixes, complete IPs, or CIDR networks
77
+ (e.g., ["10.1.", "198.84.193.157", "100.64.0.0/10"]).
78
+ algorithm: Algorithm engine to use: "auto" (default, uses modern),
79
+ "modern" (v4 enhanced engine), or "legacy" (frozen v3 engine).
80
+ default_strict: Used whenever strict is omitted: Depends(self),
81
+ dependency() with no argument, IpWareMiddleware, and
82
+ get_client_ip_from_request.
83
+ """
84
+ if precedence is None:
85
+ precedence = DEFAULT_PRECEDENCE
86
+
87
+ # Store FastAPI-style precedence for reference
88
+ self._fastapi_precedence = precedence
89
+ self.default_strict = default_strict
90
+
91
+ # Expand precedence to support both natural and WSGI formats
92
+ expanded_precedence: list[str] = []
93
+ for header in precedence:
94
+ if header == "REMOTE_ADDR":
95
+ if "REMOTE_ADDR" not in expanded_precedence:
96
+ expanded_precedence.append("REMOTE_ADDR")
97
+ elif header.startswith("HTTP_"):
98
+ if header not in expanded_precedence:
99
+ expanded_precedence.append(header)
100
+ non_http = header[5:].replace("_", "-")
101
+ if non_http not in expanded_precedence:
102
+ expanded_precedence.append(non_http)
103
+ else:
104
+ if header not in expanded_precedence:
105
+ expanded_precedence.append(header)
106
+ wsgi_form = f"HTTP_{header.upper().replace('-', '_')}"
107
+ if wsgi_form not in expanded_precedence:
108
+ expanded_precedence.append(wsgi_form)
109
+
110
+ super().__init__(
111
+ tuple(expanded_precedence),
112
+ leftmost=leftmost,
113
+ proxy_count=proxy_count,
114
+ proxy_list=proxy_list,
115
+ algorithm=algorithm,
116
+ )
117
+
118
+ @property
119
+ def precedence(self) -> tuple[str, ...]:
120
+ """Header precedence tuple from the active engine."""
121
+ return getattr(self.engine, "precedence", ())
122
+
123
+ @property
124
+ def leftmost(self) -> bool:
125
+ """Leftmost setting from the active engine."""
126
+ return getattr(self.engine, "leftmost", True)
127
+
128
+ @property
129
+ def proxy_count(self) -> int | None:
130
+ """Proxy count setting from the active engine."""
131
+ return getattr(self.engine, "proxy_count", None)
132
+
133
+ @property
134
+ def proxy_list(self) -> list[str]:
135
+ """Proxy list setting from the active engine."""
136
+ return getattr(self.engine, "proxy_list", [])
137
+
138
+ def __call__(self, request: HTTPConnection) -> ClientIpResult:
139
+ """FastAPI route dependency injection entrypoint."""
140
+ return self.get_client_ip_from_request(request, strict=self.default_strict)
141
+
142
+ def dependency(
143
+ self, strict: bool | None = None
144
+ ) -> Callable[[HTTPConnection], ClientIpResult]:
145
+ """Return a FastAPI dependency. An omitted strict flag uses default_strict."""
146
+
147
+ def _dep(request: HTTPConnection) -> ClientIpResult:
148
+ return self.get_client_ip_from_request(request, strict=strict)
149
+
150
+ return _dep
151
+
152
+ def get_ip(self, request: HTTPConnection) -> IpAddressType | None:
153
+ """Return only the client IP address (or None if unresolvable)."""
154
+ ip, _ = self.get_client_ip_from_request(request, strict=self.default_strict)
155
+ return ip
156
+
157
+ def get_ip_str(self, request: HTTPConnection) -> str | None:
158
+ """Return the client IP address as a string (or None if unresolvable)."""
159
+ ip = self.get_ip(request)
160
+ return str(ip) if ip is not None else None
161
+
162
+ def get_client_ip_from_request(
163
+ self, request: HTTPConnection, strict: bool | None = None
164
+ ) -> ClientIpResult:
165
+ """
166
+ Get client IP address from a FastAPI/Starlette Request or WebSocket connection.
167
+
168
+ Args:
169
+ request: FastAPI/Starlette Request or HTTPConnection object
170
+ strict: If True, enforce exact proxy count/list match.
171
+ If False, allow more proxies than specified.
172
+ If None, use self.default_strict.
173
+
174
+ Returns:
175
+ Tuple of (ip_address, trusted_route) where:
176
+ - ip_address: IPv4Address or IPv6Address object (or None if not found)
177
+ - trusted_route: True if request came through trusted proxies, False otherwise
178
+ """
179
+ if strict is None:
180
+ strict = self.default_strict
181
+
182
+ # Populate both WSGI-style headers and original Starlette headers
183
+ meta = {
184
+ f"HTTP_{name.upper().replace('-', '_')}": value
185
+ for name, value in request.headers.items()
186
+ }
187
+ for name, value in request.headers.items():
188
+ meta[name] = value
189
+
190
+ if request.client:
191
+ # Map Starlette's connection info to REMOTE_ADDR fallback
192
+ meta["REMOTE_ADDR"] = request.client.host
193
+
194
+ return self.get_client_ip(meta, strict=strict)
195
+
196
+
197
+ class IpWareMiddleware:
198
+ """
199
+ ASGI middleware that resolves the client IP address and attaches it to request state:
200
+ - `request.state.client_ip`: IPv4Address | IPv6Address | None
201
+ - `request.state.ip_trusted`: bool
202
+ - `request.state.client_ip_str`: str | None
203
+
204
+ Pass `ipware` to use a configured resolver. `strict` overrides that
205
+ resolver's `default_strict`; omit it to use `default_strict`.
206
+ """
207
+
208
+ def __init__(
209
+ self,
210
+ app: ASGIApp,
211
+ ipware: FastAPIIpWare | None = None,
212
+ strict: bool | None = None,
213
+ ) -> None:
214
+ self.app = app
215
+ self.ipware = ipware if ipware is not None else FastAPIIpWare()
216
+ self.strict = strict
217
+
218
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
219
+ if scope["type"] in ("http", "websocket"):
220
+ conn = HTTPConnection(scope)
221
+ ip, trusted = self.ipware.get_client_ip_from_request(
222
+ conn, strict=self.strict
223
+ )
224
+ scope.setdefault("state", {})
225
+ scope["state"]["client_ip"] = ip
226
+ scope["state"]["ip_trusted"] = trusted
227
+ scope["state"]["client_ip_str"] = str(ip) if ip is not None else None
228
+
229
+ await self.app(scope, receive, send)
230
+
231
+
232
+ __all__ = [
233
+ "DEFAULT_PRECEDENCE",
234
+ "Algorithm",
235
+ "ClientIpResult",
236
+ "FastAPIIpWare",
237
+ "IpAddressType",
238
+ "IpWareMiddleware",
239
+ "__version__",
240
+ ]
@@ -0,0 +1,34 @@
1
+ """Version handling for fastapi-ipware."""
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+
7
+ def is_local_source_checkout() -> bool:
8
+ """Check if the code is running from a local source checkout."""
9
+ package_dir = Path(__file__).resolve().parent
10
+ # Since this is a flat layout (module-root = ""), repo root is the parent of the package dir
11
+ repo_root = package_dir.parent
12
+
13
+ return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the version string, appending .dev if running from source."""
18
+ try:
19
+ # Try to get the version of the installed package
20
+ version = importlib.metadata.version("fastapi-ipware")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ # Fallback for local development if not installed
23
+ version = "0.1.0"
24
+
25
+ if not is_local_source_checkout():
26
+ return version
27
+
28
+ if version.endswith(".dev"):
29
+ return version
30
+
31
+ return f"{version}.dev"
32
+
33
+
34
+ __version__ = get_version()
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "fastapi-ipware"
3
+ version = "0.2.0"
4
+ description = "FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses"
5
+ keywords = [
6
+ "fastapi",
7
+ "starlette",
8
+ "ipware",
9
+ "ip-address",
10
+ "client-ip",
11
+ "proxy",
12
+ "x-forwarded-for",
13
+ ]
14
+ readme = "README.md"
15
+ requires-python = ">=3.12"
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+ dependencies = [
24
+ "python-ipware>=4.1.0",
25
+ "starlette>=0.49.1",
26
+ ]
27
+
28
+ [[project.authors]]
29
+ name = "Michael Bianco"
30
+ email = "mike@mikebian.co"
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/iloveitaly/fastapi-ipware"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.0,<0.14"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-root = ""
41
+
42
+ [tool.pyright]
43
+ exclude = [
44
+ "examples/",
45
+ "playground/",
46
+ "tmp/",
47
+ ".venv/",
48
+ "tests/",
49
+ ]
50
+
51
+ [tool.pytest.ini_options]
52
+ addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
53
+
54
+ [tool.coverage.run]
55
+ plugins = ["covdefaults"]
56
+ source = ["fastapi_ipware"]
57
+
58
+ [tool.coverage.report]
59
+ fail_under = 50
60
+
61
+ [dependency-groups]
62
+ dev = [
63
+ "pytest>=8.3.4",
64
+ "pyright[nodejs]>=1.1.408",
65
+ "ruff>=0.15.0",
66
+ "coverage>=7.13.4",
67
+ "pytest-cov>=7.0.0",
68
+ "covdefaults>=2.3.0",
69
+ "beautiful-traceback>=1.0.2",
70
+ "fastapi>=0.128.0",
71
+ "httpx>=0.28.1",
72
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-ipware"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses"
5
5
  keywords = [
6
6
  "fastapi",
@@ -12,15 +12,22 @@ keywords = [
12
12
  "x-forwarded-for",
13
13
  ]
14
14
  readme = "README.md"
15
- requires-python = ">=3.10"
16
- dependencies = ["python-ipware>=3.0.0", "starlette>=0.49.1"]
15
+ requires-python = ">=3.12"
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+ dependencies = ["python-ipware>=4.1.0", "starlette>=0.49.1"]
17
24
  authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
18
25
  urls = { "Repository" = "https://github.com/iloveitaly/fastapi-ipware" }
19
26
 
20
27
  # additional packaging information: https://packaging.python.org/en/latest/specifications/core-metadata/#license
21
28
 
22
29
  [build-system]
23
- requires = ["uv_build>=0.9.0,<0.10.0"]
30
+ requires = ["uv_build>=0.11.0,<0.14"]
24
31
  build-backend = "uv_build"
25
32
 
26
33
  [tool.uv.build-backend]
@@ -29,9 +36,13 @@ module-root = ""
29
36
 
30
37
  [dependency-groups]
31
38
  dev = [
32
- "pytest>=8.3.3",
33
- "pyright[nodejs]>=1.1.380",
34
- "ruff>=0.8.0",
39
+ "pytest>=8.3.4",
40
+ "pyright[nodejs]>=1.1.408",
41
+ "ruff>=0.15.0",
42
+ "coverage>=7.13.4",
43
+ "pytest-cov>=7.0.0",
44
+ "covdefaults>=2.3.0",
45
+ "beautiful-traceback>=1.0.2",
35
46
  "fastapi>=0.128.0",
36
47
  "httpx>=0.28.1",
37
48
  ]
@@ -39,5 +50,12 @@ dev = [
39
50
  [tool.pyright]
40
51
  exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
41
52
 
42
- [tool.ruff]
43
- extend-exclude = ["playground.py", "playground/"]
53
+ [tool.pytest.ini_options]
54
+ addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
55
+
56
+ [tool.coverage.run]
57
+ plugins = ["covdefaults"]
58
+ source = ["fastapi_ipware"]
59
+
60
+ [tool.coverage.report]
61
+ fail_under = 50
@@ -1,154 +0,0 @@
1
- # fastapi-ipware
2
-
3
- A FastAPI/Starlette-native wrapper for [python-ipware](https://github.com/un33k/python-ipware) that eliminates the need for WSGI-style header conversion.
4
-
5
- `python-ipware` expects WSGI-style headers (`HTTP_X_FORWARDED_FOR`), but FastAPI uses natural header names (`X-Forwarded-For`). This wrapper handles the conversion automatically so you don't have to.
6
-
7
- Also, the default precedence order is optimized for modern cloud deployments. See the [default precedence configuration](https://github.com/iloveitaly/fastapi-ipware/blob/main/fastapi_ipware/__init__.py#L48-L58) in the source code. This is different from the
8
- default ordering ipware which deprioritizes platform-specific headers, which is often the wrong ordering if you are using something like CloudFlare.
9
-
10
- ## Features
11
-
12
- - **Zero conversion overhead** - Headers converted once at initialization, not on every request
13
- - **FastAPI-native API** - Works directly with FastAPI/Starlette `Request` objects
14
- - **Customizable precedence** - Easy to configure header priority for your infrastructure
15
- - **Proxy validation** - Supports trusted proxy lists and proxy count validation
16
-
17
- ## Installation
18
-
19
- ```bash
20
- uv add fastapi-ipware
21
- ```
22
-
23
- ## Quick Start
24
-
25
- [Take a look at this example usage.](https://github.com/iloveitaly/structlog-config/blob/1e32f7afd206485b1356b322596cc19cb0bef8ba/structlog_config/fastapi_access_logger.py#L57-L72)
26
-
27
- ```python
28
- from fastapi import FastAPI, Request
29
- from fastapi_ipware import FastAPIIpWare
30
-
31
- app = FastAPI()
32
- ipware = FastAPIIpWare()
33
-
34
- @app.get("/")
35
- async def get_ip(request: Request):
36
- ip, trusted = ipware.get_client_ip_from_request(request)
37
-
38
- if ip:
39
- return {
40
- "ip": str(ip),
41
- "trusted": trusted,
42
- "is_public": ip.is_global,
43
- "is_private": ip.is_private,
44
- }
45
-
46
- return {"error": "Could not determine IP"}
47
- ```
48
-
49
- ## Usage
50
-
51
- ### Basic Usage
52
-
53
- ```python
54
- from fastapi_ipware import FastAPIIpWare
55
-
56
- # Use default configuration (optimized for FastAPI/cloud deployments)
57
- ipware = FastAPIIpWare()
58
-
59
- ip, trusted = ipware.get_client_ip_from_request(request)
60
- ```
61
-
62
- ### Custom Header Precedence
63
-
64
- Customize which headers are checked and in what order:
65
-
66
- ```python
67
- # Prioritize Cloudflare headers
68
- ipware = FastAPIIpWare(
69
- precedence=(
70
- "CF-Connecting-IP",
71
- "X-Forwarded-For",
72
- "X-Real-IP",
73
- )
74
- )
75
-
76
- # NGINX configuration
77
- ipware = FastAPIIpWare(
78
- precedence=(
79
- "X-Real-IP",
80
- "X-Forwarded-For",
81
- )
82
- )
83
- ```
84
-
85
- ### Proxy Count Validation
86
-
87
- Validate that requests pass through the expected number of proxies:
88
-
89
- ```python
90
- # Expect exactly 1 proxy (e.g., AWS ALB)
91
- ipware = FastAPIIpWare(proxy_count=1)
92
-
93
- # In strict mode, must be exactly 1 proxy
94
- ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
95
-
96
- # In non-strict mode, allow 1 or more proxies
97
- ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
98
- ```
99
-
100
- ### Trusted Proxy List
101
-
102
- Validate that requests pass through specific trusted proxies:
103
-
104
- ```python
105
- # Trust specific proxy IP prefixes
106
- ipware = FastAPIIpWare(
107
- proxy_list=["10.0.", "10.1."] # AWS internal IPs
108
- )
109
-
110
- ip, trusted = ipware.get_client_ip_from_request(request)
111
-
112
- # trusted=True only if request came through specified proxies
113
- ```
114
-
115
- ### Combined Validation
116
-
117
- Use both proxy count and trusted proxy list:
118
-
119
- ```python
120
- # Expect 1 proxy from a specific IP range
121
- ipware = FastAPIIpWare(
122
- proxy_count=1,
123
- proxy_list=["10.0."]
124
- )
125
- ```
126
-
127
- ## IP Address Types
128
-
129
- The returned IP address object has useful properties:
130
-
131
- ```python
132
- ip, _ = ipware.get_client_ip_from_request(request)
133
-
134
- if ip:
135
- print(f"Is public: {ip.is_global}")
136
- print(f"Is private: {ip.is_private}")
137
- print(f"Is loopback: {ip.is_loopback}")
138
- print(f"Is multicast: {ip.is_multicast}")
139
- ```
140
-
141
- python-ipware automatically prefers:
142
-
143
- 1. Public (global) IPs first
144
- 2. Private IPs second
145
- 3. Loopback IPs last
146
-
147
- ## License
148
-
149
- [MIT License](LICENSE.md)
150
-
151
- ## Credits
152
-
153
- - Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
154
- - https://github.com/long2ice/fastapi-limiter/blob/8d179c058fa2aaf98f3450c9026a7300ae2b3bdd/fastapi_limiter/__init__.py#L11
@@ -1,133 +0,0 @@
1
- import ipaddress
2
-
3
- from python_ipware.python_ipware import IpWare # type: ignore[import-not-found]
4
- from starlette.requests import Request
5
-
6
-
7
- class FastAPIIpWare(IpWare):
8
- """
9
- A FastAPI/Starlette-native wrapper around python-ipware that eliminates
10
- the need for WSGI-style header conversion at request time.
11
-
12
- This class accepts natural header names (e.g., "X-Forwarded-For") and
13
- handles the conversion to ipware's expected format internally.
14
-
15
- Example:
16
- >>> from fastapi_ipware import FastAPIIpWare
17
- >>> ipware = FastAPIIpWare()
18
- >>> ip, trusted = ipware.get_client_ip_from_request(request)
19
-
20
- >>> # With custom precedence
21
- >>> ipware = FastAPIIpWare(
22
- ... precedence=("CF-Connecting-IP", "X-Forwarded-For"),
23
- ... proxy_count=1
24
- ... )
25
- """
26
-
27
- def __init__(
28
- self,
29
- precedence: tuple[str, ...] | None = None,
30
- leftmost: bool = True,
31
- proxy_count: int | None = None,
32
- proxy_list: list[str] | None = None,
33
- ):
34
- """
35
- Initialize FastAPIIpWare with optional configuration.
36
-
37
- Args:
38
- precedence: Tuple of header names to check in order. Uses natural header
39
- names with dashes (e.g., "X-Forwarded-For", "X-Real-IP").
40
- If None, uses FastAPI-optimized defaults.
41
- leftmost: If True, use leftmost IP in comma-separated list (standard).
42
- If False, use rightmost IP (rare legacy configurations).
43
- proxy_count: Expected number of proxies between client and server.
44
- Used to validate and extract the correct client IP.
45
- proxy_list: List of trusted proxy IP prefixes (e.g., ["10.1.", "10.2.3"]).
46
- """
47
- # Header precedence order: Provider-specific headers before generic ones
48
- #
49
- # We prioritize provider-specific headers (CF-Connecting-IP, True-Client-IP, etc.)
50
- # over generic headers (X-Forwarded-For, X-Real-IP) because:
51
- # 1. Provider headers are set by trusted CDN/proxy infrastructure
52
- # 2. They cannot be spoofed by clients
53
- # 3. They represent the most reliable source of client IP information
54
- #
55
- # Generic headers like X-Forwarded-For can be set by anyone and are easier
56
- # to manipulate, so they should only be used as fallbacks.
57
- if precedence is None:
58
- precedence = (
59
- # Provider-specific headers (highest reliability)
60
- "CF-Connecting-IP", # Cloudflare
61
- "True-Client-IP", # Cloudflare Enterprise
62
- "Fastly-Client-IP", # Fastly, Firebase
63
- "X-Client-IP", # Microsoft Azure
64
- "X-Cluster-Client-IP", # Rackspace Cloud Load Balancers
65
- # Generic headers (fallback)
66
- "X-Forwarded-For", # Generic, used by AWS ELB, nginx, etc.
67
- "X-Real-IP", # NGINX
68
- # NOTE: Upstream python-ipware treats Forwarded headers as plain IP lists.
69
- # RFC 7239 `Forwarded` parameters (for=) are not parsed.
70
- "Forwarded-For", # RFC 7239 (plain IP list only)
71
- "Forwarded", # RFC 7239 (plain IP list only)
72
- "Client-IP", # Akamai, Cloudflare
73
- "REMOTE_ADDR", # Direct connection fallback
74
- )
75
-
76
- # Store FastAPI-style precedence for reference
77
- self._fastapi_precedence = precedence
78
-
79
- # Convert user-friendly header names (with dashes) to WSGI format once.
80
- # REMOTE_ADDR is a WSGI/ASGI convention, not an HTTP header, so it must
81
- # not be prefixed with HTTP_.
82
- wsgi_precedence = tuple(
83
- "REMOTE_ADDR"
84
- if header == "REMOTE_ADDR"
85
- else f"HTTP_{header.upper().replace('-', '_')}"
86
- for header in precedence
87
- )
88
-
89
- # Initialize parent class with WSGI-style headers
90
- super().__init__(wsgi_precedence, leftmost, proxy_count, proxy_list)
91
-
92
- def get_client_ip_from_request(
93
- self, request: Request, strict: bool = False
94
- ) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | None, bool]:
95
- """
96
- Get client IP address from a FastAPI/Starlette Request object.
97
-
98
- This is the main method you should use with FastAPI/Starlette applications.
99
- It handles the header conversion automatically.
100
-
101
- Args:
102
- request: FastAPI/Starlette Request object
103
- strict: If True, enforce exact proxy count/list match.
104
- If False, allow more proxies than specified.
105
-
106
- Returns:
107
- Tuple of (ip_address, trusted_route) where:
108
- - ip_address: IPv4Address or IPv6Address object (or None if not found)
109
- - trusted_route: True if request came through trusted proxies, False otherwise
110
-
111
- Example:
112
- >>> ip, trusted = ipware.get_client_ip_from_request(request)
113
- >>> if ip:
114
- ... print(f"Client IP: {ip}")
115
- ... print(f"Is global: {ip.is_global}")
116
- ... print(f"Is private: {ip.is_private}")
117
- """
118
- # Convert Starlette headers to WSGI-style dict that parent class expects
119
- # This happens once per request
120
- meta = {
121
- f"HTTP_{name.upper().replace('-', '_')}": value
122
- for name, value in request.headers.items()
123
- }
124
-
125
- if request.client:
126
- # NOTE: python-ipware falls back to REMOTE_ADDR when no headers match.
127
- # Map Starlette's connection info to that expected key.
128
- meta["REMOTE_ADDR"] = request.client.host
129
-
130
- return self.get_client_ip(meta, strict=strict)
131
-
132
-
133
- __all__ = ["FastAPIIpWare"]