fastapi-ipware 0.1.2__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.
@@ -1,16 +1,26 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastapi-ipware
3
- Version: 0.1.2
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
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,36 @@ 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
+ ### Using FastAPI Dependency Injection
49
+
50
+ ```python
51
+ from typing import Annotated
52
+ from fastapi import Depends, FastAPI
53
+ from fastapi_ipware import ClientIpResult, FastAPIIpWare
54
+
55
+ app = FastAPI()
56
+ ipware = FastAPIIpWare()
57
+
58
+
59
+ @app.get("/")
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
63
+ ip, trusted = client
64
+ return {
65
+ "ip": str(ip) if ip else None,
66
+ "trusted": trusted,
67
+ "is_public": ip.is_global if ip else None,
68
+ }
69
+
70
+
71
+ # Or inject only the IP object or string directly:
72
+ @app.get("/ip-string")
73
+ async def get_ip_string(ip_str: Annotated[str | None, Depends(ipware.get_ip_str)]):
74
+ return {"ip": ip_str}
75
+ ```
76
+
77
+ ### Using Request Directly
39
78
 
40
79
  ```python
41
80
  from fastapi import FastAPI, Request
@@ -44,32 +83,40 @@ from fastapi_ipware import FastAPIIpWare
44
83
  app = FastAPI()
45
84
  ipware = FastAPIIpWare()
46
85
 
86
+
47
87
  @app.get("/")
48
88
  async def get_ip(request: Request):
49
89
  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"}
90
+ return {"ip": str(ip) if ip else None, "trusted": trusted}
60
91
  ```
61
92
 
62
- ## Usage
93
+ ### Using ASGI Middleware
63
94
 
64
- ### Basic Usage
95
+ Automatically extract the client IP onto `request.state` for every request:
65
96
 
66
97
  ```python
67
- from fastapi_ipware import FastAPIIpWare
98
+ from fastapi import FastAPI, Request
99
+ from fastapi_ipware import IpWareMiddleware
68
100
 
69
- # Use default configuration (optimized for FastAPI/cloud deployments)
70
- ipware = FastAPIIpWare()
101
+ app = FastAPI()
102
+ app.add_middleware(IpWareMiddleware)
71
103
 
72
- ip, trusted = ipware.get_client_ip_from_request(request)
104
+
105
+ @app.get("/")
106
+ async def get_ip(request: Request):
107
+ return {
108
+ "ip": request.state.client_ip_str,
109
+ "trusted": request.state.ip_trusted,
110
+ }
111
+ ```
112
+
113
+ Pass a configured resolver when you need one. Omit `strict` to use that resolver's `default_strict`; pass `strict=` to override it.
114
+
115
+ ```python
116
+ from fastapi_ipware import FastAPIIpWare, IpWareMiddleware
117
+
118
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
119
+ app.add_middleware(IpWareMiddleware, ipware=ipware)
73
120
  ```
74
121
 
75
122
  ### Custom Header Precedence
@@ -100,8 +147,13 @@ ipware = FastAPIIpWare(
100
147
  Validate that requests pass through the expected number of proxies:
101
148
 
102
149
  ```python
103
- # Expect exactly 1 proxy (e.g., AWS ALB)
104
- ipware = FastAPIIpWare(proxy_count=1)
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.
155
+ # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
156
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
105
157
 
106
158
  # In strict mode, must be exactly 1 proxy
107
159
  ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
@@ -112,12 +164,12 @@ ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
112
164
 
113
165
  ### Trusted Proxy List
114
166
 
115
- Validate that requests pass through specific trusted proxies:
167
+ Validate that requests pass through specific trusted proxies (supports IP prefixes, exact IPs, and CIDR networks):
116
168
 
117
169
  ```python
118
- # Trust specific proxy IP prefixes
170
+ # Trust specific proxy IP prefixes or CIDR networks
119
171
  ipware = FastAPIIpWare(
120
- proxy_list=["10.0.", "10.1."] # AWS internal IPs
172
+ proxy_list=["10.0.", "10.1.", "100.64.0.0/10"] # AWS internal IPs and CGNAT CIDR
121
173
  )
122
174
 
123
175
  ip, trusted = ipware.get_client_ip_from_request(request)
@@ -131,10 +183,19 @@ Use both proxy count and trusted proxy list:
131
183
 
132
184
  ```python
133
185
  # Expect 1 proxy from a specific IP range
134
- ipware = FastAPIIpWare(
135
- proxy_count=1,
136
- proxy_list=["10.0."]
137
- )
186
+ ipware = FastAPIIpWare(proxy_count=1, proxy_list=["10.0."])
187
+ ```
188
+
189
+ ### Algorithm Engine Selection
190
+
191
+ Choose between python-ipware 4.x engines (`auto` / `modern` / `legacy`):
192
+
193
+ ```python
194
+ # Modern engine (default): enhanced header parsing and RFC 7239 support
195
+ ipware = FastAPIIpWare(algorithm="modern")
196
+
197
+ # Legacy engine: frozen byte-for-byte v3 behavior
198
+ ipware = FastAPIIpWare(algorithm="legacy")
138
199
  ```
139
200
 
140
201
  ## IP Address Types
@@ -165,3 +226,7 @@ python-ipware automatically prefers:
165
226
 
166
227
  - Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
167
228
  - https://github.com/long2ice/fastapi-limiter/blob/8d179c058fa2aaf98f3450c9026a7300ae2b3bdd/fastapi_limiter/__init__.py#L11
229
+
230
+ ---
231
+
232
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,214 @@
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
+ ### Using FastAPI Dependency Injection
31
+
32
+ ```python
33
+ from typing import Annotated
34
+ from fastapi import Depends, FastAPI
35
+ from fastapi_ipware import ClientIpResult, FastAPIIpWare
36
+
37
+ app = FastAPI()
38
+ ipware = FastAPIIpWare()
39
+
40
+
41
+ @app.get("/")
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
45
+ ip, trusted = client
46
+ return {
47
+ "ip": str(ip) if ip else None,
48
+ "trusted": trusted,
49
+ "is_public": ip.is_global if ip else None,
50
+ }
51
+
52
+
53
+ # Or inject only the IP object or string directly:
54
+ @app.get("/ip-string")
55
+ async def get_ip_string(ip_str: Annotated[str | None, Depends(ipware.get_ip_str)]):
56
+ return {"ip": ip_str}
57
+ ```
58
+
59
+ ### Using Request Directly
60
+
61
+ ```python
62
+ from fastapi import FastAPI, Request
63
+ from fastapi_ipware import FastAPIIpWare
64
+
65
+ app = FastAPI()
66
+ ipware = FastAPIIpWare()
67
+
68
+
69
+ @app.get("/")
70
+ async def get_ip(request: Request):
71
+ ip, trusted = ipware.get_client_ip_from_request(request)
72
+ return {"ip": str(ip) if ip else None, "trusted": trusted}
73
+ ```
74
+
75
+ ### Using ASGI Middleware
76
+
77
+ Automatically extract the client IP onto `request.state` for every request:
78
+
79
+ ```python
80
+ from fastapi import FastAPI, Request
81
+ from fastapi_ipware import IpWareMiddleware
82
+
83
+ app = FastAPI()
84
+ app.add_middleware(IpWareMiddleware)
85
+
86
+
87
+ @app.get("/")
88
+ async def get_ip(request: Request):
89
+ return {
90
+ "ip": request.state.client_ip_str,
91
+ "trusted": request.state.ip_trusted,
92
+ }
93
+ ```
94
+
95
+ Pass a configured resolver when you need one. Omit `strict` to use that resolver's `default_strict`; pass `strict=` to override it.
96
+
97
+ ```python
98
+ from fastapi_ipware import FastAPIIpWare, IpWareMiddleware
99
+
100
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
101
+ app.add_middleware(IpWareMiddleware, ipware=ipware)
102
+ ```
103
+
104
+ ### Custom Header Precedence
105
+
106
+ Customize which headers are checked and in what order:
107
+
108
+ ```python
109
+ # Prioritize Cloudflare headers
110
+ ipware = FastAPIIpWare(
111
+ precedence=(
112
+ "CF-Connecting-IP",
113
+ "X-Forwarded-For",
114
+ "X-Real-IP",
115
+ )
116
+ )
117
+
118
+ # NGINX configuration
119
+ ipware = FastAPIIpWare(
120
+ precedence=(
121
+ "X-Real-IP",
122
+ "X-Forwarded-For",
123
+ )
124
+ )
125
+ ```
126
+
127
+ ### Proxy Count Validation
128
+
129
+ Validate that requests pass through the expected number of proxies:
130
+
131
+ ```python
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.
137
+ # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
138
+ ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
139
+
140
+ # In strict mode, must be exactly 1 proxy
141
+ ip, trusted = ipware.get_client_ip_from_request(request, strict=True)
142
+
143
+ # In non-strict mode, allow 1 or more proxies
144
+ ip, trusted = ipware.get_client_ip_from_request(request, strict=False)
145
+ ```
146
+
147
+ ### Trusted Proxy List
148
+
149
+ Validate that requests pass through specific trusted proxies (supports IP prefixes, exact IPs, and CIDR networks):
150
+
151
+ ```python
152
+ # Trust specific proxy IP prefixes or CIDR networks
153
+ ipware = FastAPIIpWare(
154
+ proxy_list=["10.0.", "10.1.", "100.64.0.0/10"] # AWS internal IPs and CGNAT CIDR
155
+ )
156
+
157
+ ip, trusted = ipware.get_client_ip_from_request(request)
158
+
159
+ # trusted=True only if request came through specified proxies
160
+ ```
161
+
162
+ ### Combined Validation
163
+
164
+ Use both proxy count and trusted proxy list:
165
+
166
+ ```python
167
+ # Expect 1 proxy from a specific IP range
168
+ ipware = FastAPIIpWare(proxy_count=1, proxy_list=["10.0."])
169
+ ```
170
+
171
+ ### Algorithm Engine Selection
172
+
173
+ Choose between python-ipware 4.x engines (`auto` / `modern` / `legacy`):
174
+
175
+ ```python
176
+ # Modern engine (default): enhanced header parsing and RFC 7239 support
177
+ ipware = FastAPIIpWare(algorithm="modern")
178
+
179
+ # Legacy engine: frozen byte-for-byte v3 behavior
180
+ ipware = FastAPIIpWare(algorithm="legacy")
181
+ ```
182
+
183
+ ## IP Address Types
184
+
185
+ The returned IP address object has useful properties:
186
+
187
+ ```python
188
+ ip, _ = ipware.get_client_ip_from_request(request)
189
+
190
+ if ip:
191
+ print(f"Is public: {ip.is_global}")
192
+ print(f"Is private: {ip.is_private}")
193
+ print(f"Is loopback: {ip.is_loopback}")
194
+ print(f"Is multicast: {ip.is_multicast}")
195
+ ```
196
+
197
+ python-ipware automatically prefers:
198
+
199
+ 1. Public (global) IPs first
200
+ 2. Private IPs second
201
+ 3. Loopback IPs last
202
+
203
+ ## License
204
+
205
+ [MIT License](LICENSE.md)
206
+
207
+ ## Credits
208
+
209
+ - Built on top of [python-ipware](https://github.com/un33k/python-ipware) by un33k.
210
+ - https://github.com/long2ice/fastapi-limiter/blob/8d179c058fa2aaf98f3450c9026a7300ae2b3bdd/fastapi_limiter/__init__.py#L11
211
+
212
+ ---
213
+
214
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,275 @@
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
+ # 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",
50
+ )
51
+
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
+
94
+ class FastAPIIpWare(IpWare):
95
+ """
96
+ A FastAPI/Starlette-native wrapper around python-ipware that eliminates
97
+ the need for WSGI-style header conversion at request time.
98
+
99
+ This class accepts natural header names (e.g., "X-Forwarded-For") and
100
+ handles the conversion to ipware's expected format internally.
101
+
102
+ Example:
103
+ >>> from fastapi_ipware import FastAPIIpWare
104
+ >>> ipware = FastAPIIpWare()
105
+ >>> ip, trusted = ipware.get_client_ip_from_request(request)
106
+
107
+ >>> # Direct FastAPI route dependency
108
+ >>> @app.get("/")
109
+ ... def endpoint(client: ClientIpResult = Depends(ipware)):
110
+ ... ip, trusted = client
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ precedence: tuple[str, ...] | None = None,
116
+ leftmost: bool = True,
117
+ proxy_count: int | None = None,
118
+ proxy_list: list[str] | None = None,
119
+ algorithm: Algorithm = "auto",
120
+ default_strict: bool = False,
121
+ ):
122
+ """
123
+ Initialize FastAPIIpWare with optional configuration.
124
+
125
+ Args:
126
+ precedence: Tuple of header names to check in order. Uses natural header
127
+ names with dashes (e.g., "X-Forwarded-For", "X-Real-IP").
128
+ If None, uses FastAPI-optimized defaults.
129
+ leftmost: If True, use leftmost IP in comma-separated list (standard).
130
+ If False, use rightmost IP (rare legacy configurations).
131
+ proxy_count: Expected number of proxies between client and server.
132
+ Used to validate and extract the correct client IP.
133
+ proxy_list: List of trusted proxy IP prefixes, complete IPs, or CIDR networks
134
+ (e.g., ["10.1.", "198.84.193.157", "100.64.0.0/10"]).
135
+ algorithm: Algorithm engine to use: "auto" (default, uses modern),
136
+ "modern" (v4 enhanced engine), or "legacy" (frozen v3 engine).
137
+ default_strict: Used whenever strict is omitted: Depends(self),
138
+ dependency() with no argument, IpWareMiddleware, and
139
+ get_client_ip_from_request.
140
+ """
141
+ if precedence is None:
142
+ precedence = DEFAULT_PRECEDENCE
143
+
144
+ # store fastapi-style precedence for reference
145
+ self._fastapi_precedence = precedence
146
+ self.default_strict = default_strict
147
+
148
+ # expand precedence to support both natural and wsgi formats
149
+ expanded_precedence = _expand_header_precedence(precedence)
150
+
151
+ super().__init__(
152
+ expanded_precedence,
153
+ leftmost=leftmost,
154
+ proxy_count=proxy_count,
155
+ proxy_list=proxy_list,
156
+ algorithm=algorithm,
157
+ )
158
+
159
+ @property
160
+ def precedence(self) -> tuple[str, ...]:
161
+ "Header precedence tuple from the active engine"
162
+ return getattr(self.engine, "precedence", ())
163
+
164
+ @property
165
+ def leftmost(self) -> bool:
166
+ "Leftmost setting from the active engine"
167
+ return getattr(self.engine, "leftmost", True)
168
+
169
+ @property
170
+ def proxy_count(self) -> int | None:
171
+ "Proxy count setting from the active engine"
172
+ return getattr(self.engine, "proxy_count", None)
173
+
174
+ @property
175
+ def proxy_list(self) -> list[str]:
176
+ "Proxy list setting from the active engine"
177
+ return getattr(self.engine, "proxy_list", [])
178
+
179
+ def __call__(self, request: HTTPConnection) -> ClientIpResult:
180
+ "FastAPI route dependency injection entrypoint"
181
+ return self.get_client_ip_from_request(request, strict=self.default_strict)
182
+
183
+ def dependency(
184
+ self, strict: bool | None = None
185
+ ) -> Callable[[HTTPConnection], ClientIpResult]:
186
+ "Return a FastAPI dependency. An omitted strict flag uses default_strict"
187
+
188
+ def _dep(request: HTTPConnection) -> ClientIpResult:
189
+ return self.get_client_ip_from_request(request, strict=strict)
190
+
191
+ return _dep
192
+
193
+ def get_ip(self, request: HTTPConnection) -> IpAddressType | None:
194
+ "Return only the client IP address (or None if unresolvable)"
195
+ ip, _ = self.get_client_ip_from_request(request, strict=self.default_strict)
196
+
197
+ return ip
198
+
199
+ def get_ip_str(self, request: HTTPConnection) -> str | None:
200
+ "Return the client IP address as a string (or None if unresolvable)"
201
+ ip = self.get_ip(request)
202
+ if ip is None:
203
+ return None
204
+
205
+ return str(ip)
206
+
207
+ def get_client_ip_from_request(
208
+ self, request: HTTPConnection, strict: bool | None = None
209
+ ) -> ClientIpResult:
210
+ """
211
+ Get client IP address from a FastAPI/Starlette Request or WebSocket connection.
212
+
213
+ Args:
214
+ request: FastAPI/Starlette Request or HTTPConnection object
215
+ strict: If True, enforce exact proxy count/list match.
216
+ If False, allow more proxies than specified.
217
+ If None, use self.default_strict.
218
+
219
+ Returns:
220
+ Tuple of (ip_address, trusted_route) where:
221
+ - ip_address: IPv4Address or IPv6Address object (or None if not found)
222
+ - trusted_route: True if request came through trusted proxies, False otherwise
223
+ """
224
+ effective_strict = self.default_strict if strict is None else strict
225
+ meta = _build_request_meta(request)
226
+
227
+ return self.get_client_ip(meta, strict=effective_strict)
228
+
229
+
230
+ class IpWareMiddleware:
231
+ """
232
+ ASGI middleware that resolves the client IP address and attaches it to request state:
233
+ - `request.state.client_ip`: IPv4Address | IPv6Address | None
234
+ - `request.state.ip_trusted`: bool
235
+ - `request.state.client_ip_str`: str | None
236
+
237
+ Pass `ipware` to use a configured resolver. `strict` overrides that
238
+ resolver's `default_strict`; omit it to use `default_strict`.
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ app: ASGIApp,
244
+ ipware: FastAPIIpWare | None = None,
245
+ strict: bool | None = None,
246
+ ) -> None:
247
+ self.app = app
248
+ self.ipware = ipware if ipware is not None else FastAPIIpWare()
249
+ self.strict = strict
250
+
251
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
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
263
+
264
+ await self.app(scope, receive, send)
265
+
266
+
267
+ __all__ = [
268
+ "DEFAULT_PRECEDENCE",
269
+ "Algorithm",
270
+ "ClientIpResult",
271
+ "FastAPIIpWare",
272
+ "IpAddressType",
273
+ "IpWareMiddleware",
274
+ "__version__",
275
+ ]
@@ -0,0 +1,35 @@
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
+
11
+ # since this is a flat layout (module-root = ""), repo root is the parent of the package dir
12
+ repo_root = package_dir.parent
13
+
14
+ return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
15
+
16
+
17
+ def get_version() -> str:
18
+ "Get the version string, appending .dev if running from source"
19
+ try:
20
+ # try to get the version of the installed package
21
+ version = importlib.metadata.version("fastapi-ipware")
22
+ except importlib.metadata.PackageNotFoundError:
23
+ # fallback for local development if not installed
24
+ version = "1.0.0"
25
+
26
+ if not is_local_source_checkout():
27
+ return version
28
+
29
+ if version.endswith(".dev"):
30
+ return version
31
+
32
+ return f"{version}.dev"
33
+
34
+
35
+ __version__ = get_version()
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "fastapi-ipware"
3
+ version = "1.0.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 = "1.0.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"]