fastapi-ipware 0.2.0__tar.gz → 1.0.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 → fastapi_ipware-1.0.0}/PKG-INFO +7 -5
- {fastapi_ipware-0.2.0 → fastapi_ipware-1.0.0}/README.md +6 -4
- {fastapi_ipware-0.2.0 → fastapi_ipware-1.0.0}/fastapi_ipware/__init__.py +106 -71
- {fastapi_ipware-0.2.0 → fastapi_ipware-1.0.0}/fastapi_ipware/version.py +8 -7
- {fastapi_ipware-0.2.0 → fastapi_ipware-1.0.0}/pyproject.toml +1 -1
- {fastapi_ipware-0.2.0 → fastapi_ipware-1.0.0}/pyproject.toml.orig +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fastapi-ipware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.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
|
|
@@ -45,10 +45,6 @@ uv add fastapi-ipware
|
|
|
45
45
|
|
|
46
46
|
## Quick Start
|
|
47
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
48
|
### Using FastAPI Dependency Injection
|
|
53
49
|
|
|
54
50
|
```python
|
|
@@ -62,6 +58,8 @@ ipware = FastAPIIpWare()
|
|
|
62
58
|
|
|
63
59
|
@app.get("/")
|
|
64
60
|
async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
|
|
61
|
+
# trusted is true only when the request came through the proxies
|
|
62
|
+
# configured with proxy_count or proxy_list
|
|
65
63
|
ip, trusted = client
|
|
66
64
|
return {
|
|
67
65
|
"ip": str(ip) if ip else None,
|
|
@@ -150,6 +148,10 @@ Validate that requests pass through the expected number of proxies:
|
|
|
150
148
|
|
|
151
149
|
```python
|
|
152
150
|
# Expect exactly 1 proxy (e.g., AWS ALB).
|
|
151
|
+
# proxy_count=N returns the address just left of the N rightmost proxies,
|
|
152
|
+
# instead of the first public address, and rejects a shorter chain.
|
|
153
|
+
# X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2 returns 203.0.113.10
|
|
154
|
+
# with no proxy_count, and 10.0.0.1 with proxy_count=1.
|
|
153
155
|
# default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
|
|
154
156
|
ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
|
|
155
157
|
|
|
@@ -27,10 +27,6 @@ uv add fastapi-ipware
|
|
|
27
27
|
|
|
28
28
|
## Quick Start
|
|
29
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
30
|
### Using FastAPI Dependency Injection
|
|
35
31
|
|
|
36
32
|
```python
|
|
@@ -44,6 +40,8 @@ ipware = FastAPIIpWare()
|
|
|
44
40
|
|
|
45
41
|
@app.get("/")
|
|
46
42
|
async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
|
|
43
|
+
# trusted is true only when the request came through the proxies
|
|
44
|
+
# configured with proxy_count or proxy_list
|
|
47
45
|
ip, trusted = client
|
|
48
46
|
return {
|
|
49
47
|
"ip": str(ip) if ip else None,
|
|
@@ -132,6 +130,10 @@ Validate that requests pass through the expected number of proxies:
|
|
|
132
130
|
|
|
133
131
|
```python
|
|
134
132
|
# Expect exactly 1 proxy (e.g., AWS ALB).
|
|
133
|
+
# proxy_count=N returns the address just left of the N rightmost proxies,
|
|
134
|
+
# instead of the first public address, and rejects a shorter chain.
|
|
135
|
+
# X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2 returns 203.0.113.10
|
|
136
|
+
# with no proxy_count, and 10.0.0.1 with proxy_count=1.
|
|
135
137
|
# default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
|
|
136
138
|
ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
|
|
137
139
|
|
|
@@ -13,27 +13,84 @@ IpAddressType = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
|
13
13
|
ClientIpResult = tuple[IpAddressType | None, bool]
|
|
14
14
|
|
|
15
15
|
DEFAULT_PRECEDENCE: tuple[str, ...] = (
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"X-
|
|
27
|
-
#
|
|
28
|
-
"X-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
"Client-IP",
|
|
33
|
-
|
|
16
|
+
# provider-specific headers (highest reliability)
|
|
17
|
+
# cloudflare
|
|
18
|
+
"CF-Connecting-IP",
|
|
19
|
+
# cloudflare enterprise, akamai
|
|
20
|
+
"True-Client-IP",
|
|
21
|
+
# fastly, firebase
|
|
22
|
+
"Fastly-Client-IP",
|
|
23
|
+
# fly.io
|
|
24
|
+
"Fly-Client-IP",
|
|
25
|
+
# microsoft azure
|
|
26
|
+
"X-Client-IP",
|
|
27
|
+
# azure front door
|
|
28
|
+
"X-Azure-ClientIP",
|
|
29
|
+
# digitalocean app platform
|
|
30
|
+
"DO-Connecting-IP",
|
|
31
|
+
# rackspace cloud load balancers
|
|
32
|
+
"X-Cluster-Client-IP",
|
|
33
|
+
# google app engine
|
|
34
|
+
"X-Appengine-User-IP",
|
|
35
|
+
# envoy / istio
|
|
36
|
+
"X-Envoy-External-Address",
|
|
37
|
+
# generic headers (fallback)
|
|
38
|
+
# generic, used by aws elb, nginx, etc
|
|
39
|
+
"X-Forwarded-For",
|
|
40
|
+
# nginx
|
|
41
|
+
"X-Real-IP",
|
|
42
|
+
# plain ip list variant
|
|
43
|
+
"Forwarded-For",
|
|
44
|
+
# rfc 7239 (for=...;proto=...)
|
|
45
|
+
"Forwarded",
|
|
46
|
+
# akamai, cloudflare fallback
|
|
47
|
+
"Client-IP",
|
|
48
|
+
# direct connection fallback
|
|
49
|
+
"REMOTE_ADDR",
|
|
34
50
|
)
|
|
35
51
|
|
|
36
52
|
|
|
53
|
+
def _expand_header_variants(header: str) -> tuple[str, ...]:
|
|
54
|
+
"Expand a single header name into its natural and WSGI variants"
|
|
55
|
+
if header == "REMOTE_ADDR":
|
|
56
|
+
return ("REMOTE_ADDR",)
|
|
57
|
+
|
|
58
|
+
if header.startswith("HTTP_"):
|
|
59
|
+
return (header, header[5:].replace("_", "-"))
|
|
60
|
+
|
|
61
|
+
return (header, f"HTTP_{header.upper().replace('-', '_')}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _expand_header_precedence(precedence: tuple[str, ...]) -> tuple[str, ...]:
|
|
65
|
+
"Expand header precedence tuple to include natural and WSGI formats"
|
|
66
|
+
expanded: list[str] = []
|
|
67
|
+
for header in precedence:
|
|
68
|
+
for variant in _expand_header_variants(header):
|
|
69
|
+
if variant in expanded:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
expanded.append(variant)
|
|
73
|
+
|
|
74
|
+
return tuple(expanded)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_request_meta(request: HTTPConnection) -> dict[str, str]:
|
|
78
|
+
"Build a metadata dictionary containing WSGI and natural headers from request"
|
|
79
|
+
# populate both wsgi-style headers and original starlette headers
|
|
80
|
+
meta = {
|
|
81
|
+
f"HTTP_{name.upper().replace('-', '_')}": value
|
|
82
|
+
for name, value in request.headers.items()
|
|
83
|
+
}
|
|
84
|
+
for name, value in request.headers.items():
|
|
85
|
+
meta[name] = value
|
|
86
|
+
|
|
87
|
+
if request.client:
|
|
88
|
+
# map starlette's connection info to remote_addr fallback
|
|
89
|
+
meta["REMOTE_ADDR"] = request.client.host
|
|
90
|
+
|
|
91
|
+
return meta
|
|
92
|
+
|
|
93
|
+
|
|
37
94
|
class FastAPIIpWare(IpWare):
|
|
38
95
|
"""
|
|
39
96
|
A FastAPI/Starlette-native wrapper around python-ipware that eliminates
|
|
@@ -84,31 +141,15 @@ class FastAPIIpWare(IpWare):
|
|
|
84
141
|
if precedence is None:
|
|
85
142
|
precedence = DEFAULT_PRECEDENCE
|
|
86
143
|
|
|
87
|
-
#
|
|
144
|
+
# store fastapi-style precedence for reference
|
|
88
145
|
self._fastapi_precedence = precedence
|
|
89
146
|
self.default_strict = default_strict
|
|
90
147
|
|
|
91
|
-
#
|
|
92
|
-
expanded_precedence
|
|
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)
|
|
148
|
+
# expand precedence to support both natural and wsgi formats
|
|
149
|
+
expanded_precedence = _expand_header_precedence(precedence)
|
|
109
150
|
|
|
110
151
|
super().__init__(
|
|
111
|
-
|
|
152
|
+
expanded_precedence,
|
|
112
153
|
leftmost=leftmost,
|
|
113
154
|
proxy_count=proxy_count,
|
|
114
155
|
proxy_list=proxy_list,
|
|
@@ -117,32 +158,32 @@ class FastAPIIpWare(IpWare):
|
|
|
117
158
|
|
|
118
159
|
@property
|
|
119
160
|
def precedence(self) -> tuple[str, ...]:
|
|
120
|
-
"
|
|
161
|
+
"Header precedence tuple from the active engine"
|
|
121
162
|
return getattr(self.engine, "precedence", ())
|
|
122
163
|
|
|
123
164
|
@property
|
|
124
165
|
def leftmost(self) -> bool:
|
|
125
|
-
"
|
|
166
|
+
"Leftmost setting from the active engine"
|
|
126
167
|
return getattr(self.engine, "leftmost", True)
|
|
127
168
|
|
|
128
169
|
@property
|
|
129
170
|
def proxy_count(self) -> int | None:
|
|
130
|
-
"
|
|
171
|
+
"Proxy count setting from the active engine"
|
|
131
172
|
return getattr(self.engine, "proxy_count", None)
|
|
132
173
|
|
|
133
174
|
@property
|
|
134
175
|
def proxy_list(self) -> list[str]:
|
|
135
|
-
"
|
|
176
|
+
"Proxy list setting from the active engine"
|
|
136
177
|
return getattr(self.engine, "proxy_list", [])
|
|
137
178
|
|
|
138
179
|
def __call__(self, request: HTTPConnection) -> ClientIpResult:
|
|
139
|
-
"
|
|
180
|
+
"FastAPI route dependency injection entrypoint"
|
|
140
181
|
return self.get_client_ip_from_request(request, strict=self.default_strict)
|
|
141
182
|
|
|
142
183
|
def dependency(
|
|
143
184
|
self, strict: bool | None = None
|
|
144
185
|
) -> Callable[[HTTPConnection], ClientIpResult]:
|
|
145
|
-
"
|
|
186
|
+
"Return a FastAPI dependency. An omitted strict flag uses default_strict"
|
|
146
187
|
|
|
147
188
|
def _dep(request: HTTPConnection) -> ClientIpResult:
|
|
148
189
|
return self.get_client_ip_from_request(request, strict=strict)
|
|
@@ -150,14 +191,18 @@ class FastAPIIpWare(IpWare):
|
|
|
150
191
|
return _dep
|
|
151
192
|
|
|
152
193
|
def get_ip(self, request: HTTPConnection) -> IpAddressType | None:
|
|
153
|
-
"
|
|
194
|
+
"Return only the client IP address (or None if unresolvable)"
|
|
154
195
|
ip, _ = self.get_client_ip_from_request(request, strict=self.default_strict)
|
|
196
|
+
|
|
155
197
|
return ip
|
|
156
198
|
|
|
157
199
|
def get_ip_str(self, request: HTTPConnection) -> str | None:
|
|
158
|
-
"
|
|
200
|
+
"Return the client IP address as a string (or None if unresolvable)"
|
|
159
201
|
ip = self.get_ip(request)
|
|
160
|
-
|
|
202
|
+
if ip is None:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
return str(ip)
|
|
161
206
|
|
|
162
207
|
def get_client_ip_from_request(
|
|
163
208
|
self, request: HTTPConnection, strict: bool | None = None
|
|
@@ -176,22 +221,10 @@ class FastAPIIpWare(IpWare):
|
|
|
176
221
|
- ip_address: IPv4Address or IPv6Address object (or None if not found)
|
|
177
222
|
- trusted_route: True if request came through trusted proxies, False otherwise
|
|
178
223
|
"""
|
|
179
|
-
if strict is None
|
|
180
|
-
|
|
224
|
+
effective_strict = self.default_strict if strict is None else strict
|
|
225
|
+
meta = _build_request_meta(request)
|
|
181
226
|
|
|
182
|
-
|
|
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)
|
|
227
|
+
return self.get_client_ip(meta, strict=effective_strict)
|
|
195
228
|
|
|
196
229
|
|
|
197
230
|
class IpWareMiddleware:
|
|
@@ -216,15 +249,17 @@ class IpWareMiddleware:
|
|
|
216
249
|
self.strict = strict
|
|
217
250
|
|
|
218
251
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
219
|
-
if scope["type"] in ("http", "websocket"):
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
252
|
+
if scope["type"] not in ("http", "websocket"):
|
|
253
|
+
await self.app(scope, receive, send)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
conn = HTTPConnection(scope)
|
|
257
|
+
ip, trusted = self.ipware.get_client_ip_from_request(conn, strict=self.strict)
|
|
258
|
+
|
|
259
|
+
scope.setdefault("state", {})
|
|
260
|
+
scope["state"]["client_ip"] = ip
|
|
261
|
+
scope["state"]["ip_trusted"] = trusted
|
|
262
|
+
scope["state"]["client_ip_str"] = str(ip) if ip is not None else None
|
|
228
263
|
|
|
229
264
|
await self.app(scope, receive, send)
|
|
230
265
|
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
"
|
|
1
|
+
"Version handling for fastapi-ipware"
|
|
2
2
|
|
|
3
3
|
import importlib.metadata
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def is_local_source_checkout() -> bool:
|
|
8
|
-
"
|
|
8
|
+
"Check if the code is running from a local source checkout"
|
|
9
9
|
package_dir = Path(__file__).resolve().parent
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
# since this is a flat layout (module-root = ""), repo root is the parent of the package dir
|
|
11
12
|
repo_root = package_dir.parent
|
|
12
13
|
|
|
13
14
|
return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def get_version() -> str:
|
|
17
|
-
"
|
|
18
|
+
"Get the version string, appending .dev if running from source"
|
|
18
19
|
try:
|
|
19
|
-
#
|
|
20
|
+
# try to get the version of the installed package
|
|
20
21
|
version = importlib.metadata.version("fastapi-ipware")
|
|
21
22
|
except importlib.metadata.PackageNotFoundError:
|
|
22
|
-
#
|
|
23
|
-
version = "
|
|
23
|
+
# fallback for local development if not installed
|
|
24
|
+
version = "1.0.0"
|
|
24
25
|
|
|
25
26
|
if not is_local_source_checkout():
|
|
26
27
|
return version
|