fastapi-ipware 0.1.1__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.2.0/PKG-INFO +230 -0
- 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.2.0/pyproject.toml.orig +61 -0
- fastapi_ipware-0.1.1/PKG-INFO +0 -214
- fastapi_ipware-0.1.1/README.md +0 -201
- fastapi_ipware-0.1.1/fastapi_ipware/__init__.py +0 -121
- fastapi_ipware-0.1.1/pyproject.toml +0 -30
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: fastapi-ipware
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses
|
|
5
|
+
Keywords: fastapi,starlette,ipware,ip-address,client-ip,proxy,x-forwarded-for
|
|
6
|
+
Author: Michael Bianco
|
|
7
|
+
Author-email: Michael Bianco <mike@mikebian.co>
|
|
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
|
|
14
|
+
Requires-Dist: starlette>=0.49.1
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Project-URL: Repository, https://github.com/iloveitaly/fastapi-ipware
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
[](https://github.com/iloveitaly/fastapi-ipware/releases)
|
|
20
|
+
[](https://pepy.tech/project/fastapi-ipware)
|
|
21
|
+

|
|
22
|
+
[](https://opensource.org/licenses/MIT)
|
|
23
|
+
|
|
24
|
+
# fastapi-ipware
|
|
25
|
+
|
|
26
|
+
A FastAPI/Starlette-native wrapper for [python-ipware](https://github.com/un33k/python-ipware) that eliminates the need for WSGI-style header conversion.
|
|
27
|
+
|
|
28
|
+
`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.
|
|
29
|
+
|
|
30
|
+
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
|
|
31
|
+
default ordering ipware which deprioritizes platform-specific headers, which is often the wrong ordering if you are using something like CloudFlare.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Zero conversion overhead** - Headers converted once at initialization, not on every request
|
|
36
|
+
- **FastAPI-native API** - Works directly with FastAPI/Starlette `Request` objects
|
|
37
|
+
- **Customizable precedence** - Easy to configure header priority for your infrastructure
|
|
38
|
+
- **Proxy validation** - Supports trusted proxy lists and proxy count validation
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add fastapi-ipware
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
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
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from fastapi import FastAPI, Request
|
|
83
|
+
from fastapi_ipware import FastAPIIpWare
|
|
84
|
+
|
|
85
|
+
app = FastAPI()
|
|
86
|
+
ipware = FastAPIIpWare()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.get("/")
|
|
90
|
+
async def get_ip(request: Request):
|
|
91
|
+
ip, trusted = ipware.get_client_ip_from_request(request)
|
|
92
|
+
return {"ip": str(ip) if ip else None, "trusted": trusted}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Using ASGI Middleware
|
|
96
|
+
|
|
97
|
+
Automatically extract the client IP onto `request.state` for every request:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fastapi import FastAPI, Request
|
|
101
|
+
from fastapi_ipware import IpWareMiddleware
|
|
102
|
+
|
|
103
|
+
app = FastAPI()
|
|
104
|
+
app.add_middleware(IpWareMiddleware)
|
|
105
|
+
|
|
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)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Custom Header Precedence
|
|
125
|
+
|
|
126
|
+
Customize which headers are checked and in what order:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
# Prioritize Cloudflare headers
|
|
130
|
+
ipware = FastAPIIpWare(
|
|
131
|
+
precedence=(
|
|
132
|
+
"CF-Connecting-IP",
|
|
133
|
+
"X-Forwarded-For",
|
|
134
|
+
"X-Real-IP",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# NGINX configuration
|
|
139
|
+
ipware = FastAPIIpWare(
|
|
140
|
+
precedence=(
|
|
141
|
+
"X-Real-IP",
|
|
142
|
+
"X-Forwarded-For",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Proxy Count Validation
|
|
148
|
+
|
|
149
|
+
Validate that requests pass through the expected number of proxies:
|
|
150
|
+
|
|
151
|
+
```python
|
|
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)
|
|
155
|
+
|
|
156
|
+
# In strict mode, must be exactly 1 proxy
|
|
157
|
+
ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
|
|
158
|
+
|
|
159
|
+
# In non-strict mode, allow 1 or more proxies
|
|
160
|
+
ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Trusted Proxy List
|
|
164
|
+
|
|
165
|
+
Validate that requests pass through specific trusted proxies (supports IP prefixes, exact IPs, and CIDR networks):
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
# Trust specific proxy IP prefixes or CIDR networks
|
|
169
|
+
ipware = FastAPIIpWare(
|
|
170
|
+
proxy_list=["10.0.", "10.1.", "100.64.0.0/10"] # AWS internal IPs and CGNAT CIDR
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
ip, trusted = ipware.get_client_ip_from_request(request)
|
|
174
|
+
|
|
175
|
+
# trusted=True only if request came through specified proxies
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Combined Validation
|
|
179
|
+
|
|
180
|
+
Use both proxy count and trusted proxy list:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
# Expect 1 proxy from a specific IP range
|
|
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")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## IP Address Types
|
|
200
|
+
|
|
201
|
+
The returned IP address object has useful properties:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
ip, _ = ipware.get_client_ip_from_request(request)
|
|
205
|
+
|
|
206
|
+
if ip:
|
|
207
|
+
print(f"Is public: {ip.is_global}")
|
|
208
|
+
print(f"Is private: {ip.is_private}")
|
|
209
|
+
print(f"Is loopback: {ip.is_loopback}")
|
|
210
|
+
print(f"Is multicast: {ip.is_multicast}")
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
python-ipware automatically prefers:
|
|
214
|
+
|
|
215
|
+
1. Public (global) IPs first
|
|
216
|
+
2. Private IPs second
|
|
217
|
+
3. Loopback IPs last
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
[MIT License](LICENSE.md)
|
|
222
|
+
|
|
223
|
+
## Credits
|
|
224
|
+
|
|
225
|
+
- Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
|
|
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
|
+
]
|