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.
- {fastapi_ipware-0.1.2 → fastapi_ipware-0.2.0}/PKG-INFO +92 -29
- fastapi_ipware-0.2.0/README.md +212 -0
- fastapi_ipware-0.2.0/fastapi_ipware/__init__.py +240 -0
- fastapi_ipware-0.2.0/fastapi_ipware/version.py +34 -0
- fastapi_ipware-0.2.0/pyproject.toml +72 -0
- fastapi_ipware-0.1.2/pyproject.toml → fastapi_ipware-0.2.0/pyproject.toml.orig +27 -9
- fastapi_ipware-0.1.2/README.md +0 -154
- fastapi_ipware-0.1.2/fastapi_ipware/__init__.py +0 -133
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fastapi-ipware
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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.
|
|
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
|
+
[](https://github.com/iloveitaly/fastapi-ipware/releases)
|
|
20
|
+
[](https://pepy.tech/project/fastapi-ipware)
|
|
21
|
+

|
|
22
|
+
[](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
|
-
|
|
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
|
-
|
|
95
|
+
### Using ASGI Middleware
|
|
63
96
|
|
|
64
|
-
|
|
97
|
+
Automatically extract the client IP onto `request.state` for every request:
|
|
65
98
|
|
|
66
99
|
```python
|
|
67
|
-
from
|
|
100
|
+
from fastapi import FastAPI, Request
|
|
101
|
+
from fastapi_ipware import IpWareMiddleware
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
app = FastAPI()
|
|
104
|
+
app.add_middleware(IpWareMiddleware)
|
|
71
105
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
+
[](https://github.com/iloveitaly/fastapi-ipware/releases)
|
|
2
|
+
[](https://pepy.tech/project/fastapi-ipware)
|
|
3
|
+

|
|
4
|
+
[](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.
|
|
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.
|
|
16
|
-
|
|
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.
|
|
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.
|
|
33
|
-
"pyright[nodejs]>=1.1.
|
|
34
|
-
"ruff>=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.
|
|
43
|
-
|
|
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
|
fastapi_ipware-0.1.2/README.md
DELETED
|
@@ -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"]
|