python-ipware 2.0.4__tar.gz → 2.0.5__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.
- {python_ipware-2.0.4/python_ipware.egg-info → python_ipware-2.0.5}/PKG-INFO +1 -1
- python_ipware-2.0.5/python_ipware/__version__.py +1 -0
- python_ipware-2.0.5/python_ipware/python_ipware.py +335 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5/python_ipware.egg-info}/PKG-INFO +1 -1
- {python_ipware-2.0.4 → python_ipware-2.0.5}/tests/tests_ipv4.py +19 -4
- {python_ipware-2.0.4 → python_ipware-2.0.5}/tests/tests_ipv6.py +9 -1
- python_ipware-2.0.4/python_ipware/__version__.py +0 -1
- python_ipware-2.0.4/python_ipware/python_ipware.py +0 -346
- {python_ipware-2.0.4 → python_ipware-2.0.5}/LICENSE +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/README.md +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/pyproject.toml +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware/__init__.py +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware/py.typed +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware.egg-info/SOURCES.txt +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware.egg-info/dependency_links.txt +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware.egg-info/requires.txt +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/python_ipware.egg-info/top_level.txt +0 -0
- {python_ipware-2.0.4 → python_ipware-2.0.5}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.5"
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
5
|
+
|
|
6
|
+
IpAddressType = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
|
7
|
+
OptionalIpAddressType = Optional[IpAddressType]
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IpWareMeta:
|
|
13
|
+
"""
|
|
14
|
+
A class to manage metadata extracted from HTTP request headers, primarily for identifying
|
|
15
|
+
the client's IP address behind load balancers and proxies.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, precedence: Optional[Tuple[str, ...]] = None, leftmost: bool = True
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Initializes the metadata handler with a precedence list of HTTP headers and the position of the client IP.
|
|
23
|
+
|
|
24
|
+
:param precedence: A tuple of header names that define the order to check for a valid IP.
|
|
25
|
+
If not provided, a default list of commonly used headers is applied.
|
|
26
|
+
:param leftmost: A boolean that indicates whether to use the left-most IP in the header
|
|
27
|
+
when multiple IPs are listed (typically the case with proxies).
|
|
28
|
+
"""
|
|
29
|
+
# Default precedence list of headers if none is provided
|
|
30
|
+
if precedence is None:
|
|
31
|
+
precedence = (
|
|
32
|
+
"X_FORWARDED_FOR", # Common header for proxies, load balancers, like AWS ELB. Default to the left-most IP.
|
|
33
|
+
"HTTP_X_FORWARDED_FOR", # Alternative header similar to `X_FORWARDED_FOR`.
|
|
34
|
+
"HTTP_CLIENT_IP", # Header used by some providers like Amazon EC2, Heroku.
|
|
35
|
+
"HTTP_X_REAL_IP", # Header used by some providers like Amazon EC2, Heroku.
|
|
36
|
+
"HTTP_X_FORWARDED", # Used by Squid and similar software.
|
|
37
|
+
"HTTP_X_CLUSTER_CLIENT_IP", # Used by Rackspace LB, Riverbed Stingray.
|
|
38
|
+
"HTTP_FORWARDED_FOR", # Standard header defined by RFC 7239.
|
|
39
|
+
"HTTP_FORWARDED", # Standard header defined by RFC 7239.
|
|
40
|
+
"HTTP_CF_CONNECTING_IP", # Used by CloudFlare.
|
|
41
|
+
"X-CLIENT-IP", # Used by Microsoft Azure.
|
|
42
|
+
"X-REAL-IP", # Commonly used by NGINX.
|
|
43
|
+
"X-CLUSTER-CLIENT-IP", # Used by Rackspace Cloud Load Balancers.
|
|
44
|
+
"X_FORWARDED", # Used by Squid.
|
|
45
|
+
"FORWARDED_FOR", # Standard header defined by RFC 7239.
|
|
46
|
+
"CF-CONNECTING-IP", # Used by CloudFlare.
|
|
47
|
+
"TRUE-CLIENT-IP", # Header for CloudFlare Enterprise.
|
|
48
|
+
"FASTLY-CLIENT-IP", # Used by Fastly, Firebase.
|
|
49
|
+
"FORWARDED", # Standard header defined by RFC 7239.
|
|
50
|
+
"CLIENT-IP", # Used by Akamai, Cloudflare's True-Client-IP, and Fastly's Fastly-Client-IP.
|
|
51
|
+
"REMOTE_ADDR", # The default IP address header (direct connection).
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self.precedence = precedence
|
|
55
|
+
self.leftmost = leftmost
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IpWareIpAddress:
|
|
59
|
+
"""
|
|
60
|
+
A class for handling and parsing IP address data from HTTP requests.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def extract_ipv4(self, ip_address: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Extracts the IPv4 address from a given string that may include a port number.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
ip_address (str): An IPv4 address possibly including a port (e.g., "192.168.1.1:8080").
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: The IPv4 address without the port. Returns an empty string if input is None.
|
|
72
|
+
"""
|
|
73
|
+
if ip_address:
|
|
74
|
+
return ip_address.split(":")[0].strip()
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
def extract_ipv6(self, ip_address: str) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Extracts the IPv6 address from a given string that may include a port number.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
ip_address (str): An IPv6 address possibly including a port (e.g., "[2001:db8::1]:8080").
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
str: The IPv6 address without brackets or port. Returns an empty string if input is None.
|
|
86
|
+
"""
|
|
87
|
+
if ip_address and "]:" in ip_address:
|
|
88
|
+
return ip_address.split("]:")[0].replace("[", "").strip()
|
|
89
|
+
return ip_address.strip() if ip_address else ""
|
|
90
|
+
|
|
91
|
+
def parse_ip_address(self, ip_str: str):
|
|
92
|
+
"""
|
|
93
|
+
Parses the given IP address string to an IPv4Address or IPv6Address object.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
ip_str (str): An IP address possibly including a port.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
ipaddress.IPv4Address|ipaddress.IPv6Address|None: The parsed IP object or None if the address is invalid.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
# First, try to parse as IPv6.
|
|
103
|
+
ipv6 = self.extract_ipv6(ip_str)
|
|
104
|
+
ip = ipaddress.IPv6Address(ipv6)
|
|
105
|
+
return ip.ipv4_mapped or ip
|
|
106
|
+
except ipaddress.AddressValueError:
|
|
107
|
+
try:
|
|
108
|
+
# If IPv6 parsing fails, try to parse as IPv4.
|
|
109
|
+
ipv4 = self.extract_ipv4(ip_str)
|
|
110
|
+
return ipaddress.IPv4Address(ipv4)
|
|
111
|
+
except ipaddress.AddressValueError:
|
|
112
|
+
# Log error if IP is invalid
|
|
113
|
+
# print(f"Invalid IP address: {ip_str}")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def get_ips_from_string(
|
|
117
|
+
self,
|
|
118
|
+
ip_str: str,
|
|
119
|
+
strict: bool = False,
|
|
120
|
+
) -> Optional[List[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]]:
|
|
121
|
+
"""
|
|
122
|
+
Parses a comma-separated list of IP addresses, each possibly including a port.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ip_str (str): A comma-separated list of IP addresses or address:port entries.
|
|
126
|
+
strict (bool): True to bail out on first invalid ip, False to skip invalid ip
|
|
127
|
+
Returns:
|
|
128
|
+
List[ipaddress.IPv4Address|ipaddress.IPv6Address]|None: A list of IP address objects or None if any IP is invalid.
|
|
129
|
+
"""
|
|
130
|
+
ip_list = []
|
|
131
|
+
for ip_address in ip_str.split(","):
|
|
132
|
+
ip = self.parse_ip_address(ip_address.strip())
|
|
133
|
+
if ip:
|
|
134
|
+
ip_list.append(ip)
|
|
135
|
+
else:
|
|
136
|
+
if strict:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
if not self.leftmost:
|
|
140
|
+
ip_list.reverse()
|
|
141
|
+
|
|
142
|
+
return ip_list
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class IpWareProxy:
|
|
146
|
+
"""
|
|
147
|
+
A class to handle proxy data from an HTTP request, including validating
|
|
148
|
+
proxy counts and trusted proxy lists.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self, proxy_count: Optional[int] = None, proxy_list: Optional[List[str]] = None
|
|
153
|
+
) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Initialize the IpWareProxy class with optional proxy count and proxy list.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
proxy_count: The expected number of proxies, can be None if no specific count is enforced.
|
|
159
|
+
proxy_list: A list of partial IP addresses as trusted proxies, can be None.
|
|
160
|
+
"""
|
|
161
|
+
if proxy_count is not None and proxy_count < 0:
|
|
162
|
+
raise ValueError("proxy_count must be non-negative")
|
|
163
|
+
self.proxy_count = proxy_count
|
|
164
|
+
self.proxy_list = self._validate_proxy_list(proxy_list or [])
|
|
165
|
+
|
|
166
|
+
def _validate_proxy_list(self, proxy_list: List[str]) -> List[str]:
|
|
167
|
+
"""
|
|
168
|
+
Validates that the proxy list contains only strings.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
proxy_list: A list of proxies.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The proxy list if all items are valid strings.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If the proxy list is not a list of strings.
|
|
178
|
+
"""
|
|
179
|
+
if not all(isinstance(ip, str) for ip in proxy_list):
|
|
180
|
+
raise ValueError("All elements in the proxy list must be strings.")
|
|
181
|
+
return proxy_list
|
|
182
|
+
|
|
183
|
+
def is_proxy_count_valid(self, ip_list: List[str], strict: bool = False) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
Validates the proxy count against a list of IP addresses.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
ip_list: A list of IP addresses from the request headers.
|
|
189
|
+
strict: If True, the number of proxies must exactly match the proxy_count.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if the proxy count is valid, False otherwise.
|
|
193
|
+
"""
|
|
194
|
+
if self.proxy_count is None:
|
|
195
|
+
return True # No proxy count specified, so always valid.
|
|
196
|
+
|
|
197
|
+
ip_count = len(ip_list)
|
|
198
|
+
if strict:
|
|
199
|
+
return ip_count - 1 == self.proxy_count
|
|
200
|
+
# Exact match required, excluding client's own IP.
|
|
201
|
+
|
|
202
|
+
return ip_count - 1 >= self.proxy_count
|
|
203
|
+
# Allow more proxies than the count, excluding client's IP.
|
|
204
|
+
|
|
205
|
+
def is_proxy_trusted_list_valid(
|
|
206
|
+
self, ip_list: List[str], strict: bool = False
|
|
207
|
+
) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Checks if all proxies in the incoming list are trusted based on the proxy_list.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
ip_list: A list of IP addresses from the request headers.
|
|
213
|
+
strict: If True, the number of proxies must exactly match the length of proxy_list.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if all proxies are trusted, False otherwise.
|
|
217
|
+
"""
|
|
218
|
+
if not self.proxy_list:
|
|
219
|
+
return True # No specific proxies to trust, so always valid.
|
|
220
|
+
|
|
221
|
+
ip_count = len(ip_list)
|
|
222
|
+
proxy_list_count = len(self.proxy_list)
|
|
223
|
+
|
|
224
|
+
if strict and ip_count - 1 != proxy_list_count:
|
|
225
|
+
return False # Strict mode: Exact count match required.
|
|
226
|
+
|
|
227
|
+
if ip_count - 1 < proxy_list_count:
|
|
228
|
+
return False # Not enough IPs to match the trusted proxies.
|
|
229
|
+
|
|
230
|
+
# Verify each proxy against the trusted list by comparing each corresponding element.
|
|
231
|
+
return all(
|
|
232
|
+
str(ip).startswith(trusted_proxy_pattern)
|
|
233
|
+
for ip, trusted_proxy_pattern in zip(
|
|
234
|
+
ip_list[-proxy_list_count:], self.proxy_list
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class IpWare(IpWareMeta, IpWareProxy, IpWareIpAddress):
|
|
240
|
+
"""
|
|
241
|
+
A class that makes a best effort to determine the client's IP address.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
precedence: Optional[Tuple[str, ...]] = None,
|
|
247
|
+
leftmost: bool = True,
|
|
248
|
+
proxy_count: Optional[int] = None,
|
|
249
|
+
proxy_list: Optional[List[str]] = None,
|
|
250
|
+
) -> None:
|
|
251
|
+
IpWareMeta.__init__(self, precedence, leftmost)
|
|
252
|
+
IpWareProxy.__init__(self, proxy_count, proxy_list)
|
|
253
|
+
|
|
254
|
+
def get_meta_value(self, meta: Dict[str, str], key: str) -> str:
|
|
255
|
+
"""
|
|
256
|
+
Returns a cleaned up version of the value for a given key.
|
|
257
|
+
@param key: The key to look up.
|
|
258
|
+
@return: The value of the key or an empty string if the key is not found.
|
|
259
|
+
"""
|
|
260
|
+
meta = meta or {}
|
|
261
|
+
return meta.get(key, meta.get(key.replace("_", "-"), "")).strip()
|
|
262
|
+
|
|
263
|
+
def get_meta_values(self, meta: Dict[str, str]) -> List[str]:
|
|
264
|
+
"""
|
|
265
|
+
Returns a list of cleaned up values for the keys defined in 'precedence'.
|
|
266
|
+
@return: A list of values.
|
|
267
|
+
"""
|
|
268
|
+
meta_list: List[str] = []
|
|
269
|
+
for key in self.precedence:
|
|
270
|
+
value = self.get_meta_value(meta, key).strip()
|
|
271
|
+
if value:
|
|
272
|
+
meta_list.append(value)
|
|
273
|
+
return meta_list
|
|
274
|
+
|
|
275
|
+
def get_client_ip(
|
|
276
|
+
self,
|
|
277
|
+
meta: Dict[str, str],
|
|
278
|
+
strict: bool = False,
|
|
279
|
+
) -> Tuple[OptionalIpAddressType, bool]:
|
|
280
|
+
"""
|
|
281
|
+
Returns the client's IP address.
|
|
282
|
+
"""
|
|
283
|
+
loopback_list: List[IpAddressType] = []
|
|
284
|
+
private_list: List[OptionalIpAddressType] = []
|
|
285
|
+
|
|
286
|
+
for ip_str in self.get_meta_values(meta):
|
|
287
|
+
ip_list = self.get_ips_from_string(ip_str, strict)
|
|
288
|
+
if not ip_list:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
proxy_count_validated = self.is_proxy_count_valid(ip_list, strict)
|
|
292
|
+
if not proxy_count_validated:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
proxy_list_validated = self.is_proxy_trusted_list_valid(ip_list, strict)
|
|
296
|
+
if not proxy_list_validated:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
client_ip, trusted_route = self.get_best_ip(ip_list)
|
|
300
|
+
if client_ip is not None:
|
|
301
|
+
if client_ip.is_global:
|
|
302
|
+
return client_ip, trusted_route
|
|
303
|
+
if client_ip.is_loopback:
|
|
304
|
+
loopback_list.append(client_ip)
|
|
305
|
+
else:
|
|
306
|
+
private_list.append(client_ip)
|
|
307
|
+
|
|
308
|
+
if private_list:
|
|
309
|
+
return private_list[0], False
|
|
310
|
+
if loopback_list:
|
|
311
|
+
return loopback_list[0], False
|
|
312
|
+
return None, False
|
|
313
|
+
|
|
314
|
+
def get_best_ip(
|
|
315
|
+
self,
|
|
316
|
+
ip_list: List[IpAddressType],
|
|
317
|
+
) -> Tuple[OptionalIpAddressType, bool]:
|
|
318
|
+
"""
|
|
319
|
+
Determines the best possible IP address for the client from a list of IP addresses.
|
|
320
|
+
"""
|
|
321
|
+
if not ip_list:
|
|
322
|
+
logger.warning("Invalid IP list provided.")
|
|
323
|
+
return None, False
|
|
324
|
+
|
|
325
|
+
if self.proxy_list and len(self.proxy_list) > 0:
|
|
326
|
+
best_client_ip_index = len(self.proxy_list) + 1
|
|
327
|
+
best_client_ip = ip_list[-best_client_ip_index]
|
|
328
|
+
return best_client_ip, True
|
|
329
|
+
|
|
330
|
+
if self.proxy_count is not None:
|
|
331
|
+
best_client_ip_index = self.proxy_count + 1
|
|
332
|
+
best_client_ip = ip_list[-best_client_ip_index]
|
|
333
|
+
return best_client_ip, True
|
|
334
|
+
|
|
335
|
+
return ip_list[0], False
|
|
@@ -77,12 +77,27 @@ class TestIPv4Common(unittest.TestCase):
|
|
|
77
77
|
"HTTP_X_FORWARDED_FOR": "unknown, 177.139.233.139, 198.84.193.157, 198.84.193.158",
|
|
78
78
|
}
|
|
79
79
|
r = self.ipware.get_client_ip(meta)
|
|
80
|
-
self.assertEqual(r, (
|
|
80
|
+
self.assertEqual(r, (IPv4Address("177.139.233.139"), False))
|
|
81
81
|
|
|
82
|
-
def
|
|
82
|
+
def test_error_only_strict(self):
|
|
83
83
|
meta = {
|
|
84
84
|
"HTTP_X_FORWARDED_FOR": "unknown, 177.139.233.139, 198.84.193.157, 198.84.193.158",
|
|
85
|
+
}
|
|
86
|
+
r = self.ipware.get_client_ip(meta, strict=True)
|
|
87
|
+
self.assertEqual(r, (None, False))
|
|
88
|
+
|
|
89
|
+
def test_error_only_first_strict(self):
|
|
90
|
+
meta = {
|
|
85
91
|
"X_FORWARDED_FOR": "177.139.233.138, 198.84.193.157, 198.84.193.158",
|
|
92
|
+
"HTTP_X_FORWARDED_FOR": "unknown, 177.139.233.139, 198.84.193.157, 198.84.193.158",
|
|
93
|
+
}
|
|
94
|
+
r = self.ipware.get_client_ip(meta, strict=True)
|
|
95
|
+
self.assertEqual(r, (IPv4Address("177.139.233.138"), False))
|
|
96
|
+
|
|
97
|
+
def test_error_first(self):
|
|
98
|
+
meta = {
|
|
99
|
+
"X_FORWARDED_FOR": "unknown, 177.139.233.138, 198.84.193.157, 198.84.193.158",
|
|
100
|
+
"HTTP_X_FORWARDED_FOR": "177.139.233.139, 198.84.193.157, 198.84.193.158",
|
|
86
101
|
}
|
|
87
102
|
r = self.ipware.get_client_ip(meta)
|
|
88
103
|
self.assertEqual(r, (IPv4Address("177.139.233.138"), False))
|
|
@@ -222,7 +237,7 @@ class TestIPv4ProxyCount(unittest.TestCase):
|
|
|
222
237
|
"HTTP_X_FORWARDED_FOR": "177.139.233.139, 198.84.193.157, 198.84.193.158",
|
|
223
238
|
}
|
|
224
239
|
r = ipware.get_client_ip(meta)
|
|
225
|
-
self.assertEqual(r, (IPv4Address("
|
|
240
|
+
self.assertEqual(r, (IPv4Address("198.84.193.158"), True))
|
|
226
241
|
|
|
227
242
|
def test_proxy_count_zero_exact_zero_proxy_pass(self):
|
|
228
243
|
ipware = IpWare(proxy_count=0)
|
|
@@ -230,7 +245,7 @@ class TestIPv4ProxyCount(unittest.TestCase):
|
|
|
230
245
|
"HTTP_X_FORWARDED_FOR": "177.139.233.139",
|
|
231
246
|
}
|
|
232
247
|
r = ipware.get_client_ip(meta, strict=True)
|
|
233
|
-
self.assertEqual(r, (IPv4Address("177.139.233.139"),
|
|
248
|
+
self.assertEqual(r, (IPv4Address("177.139.233.139"), True))
|
|
234
249
|
|
|
235
250
|
def test_proxy_count_zero_exact_zero_proxy_fail(self):
|
|
236
251
|
ipware = IpWare(proxy_count=0)
|
|
@@ -69,14 +69,22 @@ class TestIPv6Common(unittest.TestCase):
|
|
|
69
69
|
"X_FORWARDED_FOR": "unknown, 3ffe:1900:4545:3:200:f8ff:fe21:67cf, 2606:4700:4700::1111, 2001:4860:4860::8888",
|
|
70
70
|
}
|
|
71
71
|
r = self.ipware.get_client_ip(meta)
|
|
72
|
+
self.assertEqual(r, (IPv6Address("3ffe:1900:4545:3:200:f8ff:fe21:67cf"), False))
|
|
73
|
+
|
|
74
|
+
def test_error_only_strict(self):
|
|
75
|
+
meta = {
|
|
76
|
+
"X_FORWARDED_FOR": "unknown, 3ffe:1900:4545:3:200:f8ff:fe21:67cf, 2606:4700:4700::1111, 2001:4860:4860::8888",
|
|
77
|
+
}
|
|
78
|
+
r = self.ipware.get_client_ip(meta, strict=True)
|
|
72
79
|
self.assertEqual(r, (None, False))
|
|
73
80
|
|
|
74
81
|
def test_first_error_bailout(self):
|
|
75
82
|
meta = {
|
|
76
83
|
"HTTP_X_FORWARDED_FOR": "unknown, 3ffe:1900:4545:3:200:f8ff:fe21:67cf, 2606:4700:4700::1111, 2001:4860:4860::8888",
|
|
84
|
+
"X_FORWARDED_FOR": "2606:4700:4700::1111, 2001:4860:4860::8888",
|
|
77
85
|
}
|
|
78
86
|
r = self.ipware.get_client_ip(meta)
|
|
79
|
-
self.assertEqual(r, (
|
|
87
|
+
self.assertEqual(r, (IPv6Address("2606:4700:4700::1111"), False))
|
|
80
88
|
|
|
81
89
|
def test_with_error_best_match(self):
|
|
82
90
|
meta = {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.0.4"
|
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
import ipaddress
|
|
2
|
-
import logging
|
|
3
|
-
|
|
4
|
-
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
5
|
-
|
|
6
|
-
IpAddressType = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
|
7
|
-
OptionalIpAddressType = Optional[IpAddressType]
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class IpWareMeta:
|
|
13
|
-
"""
|
|
14
|
-
A class that handles meta data frm an HTTP request.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
def __init__(
|
|
18
|
-
self,
|
|
19
|
-
precedence: Optional[Tuple[str, ...]] = None,
|
|
20
|
-
leftmost: bool = True,
|
|
21
|
-
) -> None:
|
|
22
|
-
self.precedence = precedence or (
|
|
23
|
-
"X_FORWARDED_FOR", # Load balancers or proxies such as AWS ELB (default client is `left-most` [`<client>, <proxy1>, <proxy2>`])
|
|
24
|
-
"HTTP_X_FORWARDED_FOR", # Similar to X_FORWARDED_TO
|
|
25
|
-
"HTTP_CLIENT_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
|
|
26
|
-
"HTTP_X_REAL_IP", # Standard headers used by providers such as Amazon EC2, Heroku etc.
|
|
27
|
-
"HTTP_X_FORWARDED", # Squid and others
|
|
28
|
-
"HTTP_X_CLUSTER_CLIENT_IP", # Rackspace LB and Riverbed Stingray
|
|
29
|
-
"HTTP_FORWARDED_FOR", # RFC 7239
|
|
30
|
-
"HTTP_FORWARDED", # RFC 7239
|
|
31
|
-
"HTTP_CF_CONNECTING_IP", # CloudFlare
|
|
32
|
-
"X-CLIENT-IP", # Microsoft Azure
|
|
33
|
-
"X-REAL-IP", # NGINX
|
|
34
|
-
"X-CLUSTER-CLIENT-IP", # Rackspace Cloud Load Balancers
|
|
35
|
-
"X_FORWARDED", # Squid
|
|
36
|
-
"FORWARDED_FOR", # RFC 7239
|
|
37
|
-
"CF-CONNECTING-IP", # CloudFlare
|
|
38
|
-
"TRUE-CLIENT-IP", # CloudFlare Enterprise,
|
|
39
|
-
"FASTLY-CLIENT-IP", # Firebase, Fastly
|
|
40
|
-
"FORWARDED", # RFC 7239
|
|
41
|
-
"CLIENT-IP", # Akamai and Cloudflare: True-Client-IP and Fastly: Fastly-Client-IP
|
|
42
|
-
"REMOTE_ADDR", # Default
|
|
43
|
-
)
|
|
44
|
-
self.leftmost = leftmost
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class IpWareIpAddress:
|
|
48
|
-
"""
|
|
49
|
-
A class that handles IP address data from an HTTP request.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
def extract_ipv4_only(self, ip_address: str) -> str:
|
|
53
|
-
"""
|
|
54
|
-
Given an IPv4 address or address:port, it extracts the IP address
|
|
55
|
-
@param ip_str: IP address or address:port
|
|
56
|
-
@return: IP address
|
|
57
|
-
"""
|
|
58
|
-
|
|
59
|
-
if ip_address:
|
|
60
|
-
# handle ipv4 addresses with port
|
|
61
|
-
if ":" in ip_address:
|
|
62
|
-
ip_address = ip_address.split(":")[0]
|
|
63
|
-
return ip_address.strip()
|
|
64
|
-
|
|
65
|
-
return ip_address.strip()
|
|
66
|
-
|
|
67
|
-
return ""
|
|
68
|
-
|
|
69
|
-
def extract_ipv6_only(self, ip_address: str) -> str:
|
|
70
|
-
"""
|
|
71
|
-
Given an IPv6 address or address:port, it extracts the IP address
|
|
72
|
-
@param ip_str: IP address or address:port
|
|
73
|
-
@return: IP address
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
if ip_address:
|
|
77
|
-
# handle ipv6 addresses with port
|
|
78
|
-
if "]:" in ip_address:
|
|
79
|
-
ip_address = ip_address.split("]:")[0]
|
|
80
|
-
ip_address = ip_address.replace("[", "")
|
|
81
|
-
return ip_address.strip()
|
|
82
|
-
|
|
83
|
-
return ip_address.strip()
|
|
84
|
-
|
|
85
|
-
return ""
|
|
86
|
-
|
|
87
|
-
def get_ip_object(
|
|
88
|
-
self,
|
|
89
|
-
ip_str: str,
|
|
90
|
-
) -> OptionalIpAddressType:
|
|
91
|
-
"""
|
|
92
|
-
Given an IP address or address:port, it parses the IP address
|
|
93
|
-
@param ip_str: IP address or address:port
|
|
94
|
-
@return: IP address of type IPv4Address or IPv6Address
|
|
95
|
-
"""
|
|
96
|
-
|
|
97
|
-
ip: OptionalIpAddressType = None
|
|
98
|
-
if ip_str:
|
|
99
|
-
try:
|
|
100
|
-
# try to parse as IPv6 address with optional port
|
|
101
|
-
ipv6 = self.extract_ipv6_only(ip_str)
|
|
102
|
-
ip = ipaddress.IPv6Address(ipv6)
|
|
103
|
-
ip = ip.ipv4_mapped or ip
|
|
104
|
-
except ipaddress.AddressValueError:
|
|
105
|
-
try:
|
|
106
|
-
# try to parse as IPv4 address with optional port
|
|
107
|
-
ipv4 = self.extract_ipv4_only(ip_str)
|
|
108
|
-
ip = ipaddress.IPv4Address(ipv4)
|
|
109
|
-
except ipaddress.AddressValueError:
|
|
110
|
-
# not a valid IP address, return None
|
|
111
|
-
logger.info("Invalid ip address. {0}".format(ip_str))
|
|
112
|
-
ip = None
|
|
113
|
-
return ip
|
|
114
|
-
|
|
115
|
-
def get_ips_from_string(
|
|
116
|
-
self,
|
|
117
|
-
ip_str: str,
|
|
118
|
-
) -> Optional[List[IpAddressType]]:
|
|
119
|
-
"""
|
|
120
|
-
Given a comma separated list of IP addresses or address:port, it parses the IP addresses
|
|
121
|
-
@param ip_str: comma separated list of IP addresses or address:port
|
|
122
|
-
@return: list of IP addresses of type IPv4Address or IPv6Address
|
|
123
|
-
"""
|
|
124
|
-
ip_list: List[IpAddressType] = []
|
|
125
|
-
|
|
126
|
-
for ip_address in ip_str.split(","):
|
|
127
|
-
ip = self.get_ip_object(ip_address.strip())
|
|
128
|
-
if ip:
|
|
129
|
-
ip_list.append(ip)
|
|
130
|
-
else:
|
|
131
|
-
# we have at least one invalid IP address, return empty list, instead
|
|
132
|
-
return None
|
|
133
|
-
|
|
134
|
-
if not self.leftmost:
|
|
135
|
-
ip_list.reverse()
|
|
136
|
-
|
|
137
|
-
return ip_list
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class IpWareProxy:
|
|
141
|
-
"""
|
|
142
|
-
A class that handles proxy data from an HTTP request.
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
def __init__(
|
|
146
|
-
self,
|
|
147
|
-
proxy_count: Optional[int] = None,
|
|
148
|
-
proxy_list: Optional[List[str]] = None,
|
|
149
|
-
) -> None:
|
|
150
|
-
self.proxy_count = proxy_count
|
|
151
|
-
self.proxy_list = self._is_valid_proxy_trusted_list(proxy_list or [])
|
|
152
|
-
|
|
153
|
-
def _is_valid_proxy_trusted_list(self, proxy_list: Any) -> List[str]:
|
|
154
|
-
"""
|
|
155
|
-
Checks if the proxy list is a valid list of strings
|
|
156
|
-
@return: proxy list or raises an exception
|
|
157
|
-
"""
|
|
158
|
-
|
|
159
|
-
if not isinstance(proxy_list, list):
|
|
160
|
-
raise ValueError("Parameter must be a list")
|
|
161
|
-
if not all(isinstance(x, str) for x in proxy_list):
|
|
162
|
-
raise ValueError("All elements in list must be strings")
|
|
163
|
-
|
|
164
|
-
return proxy_list
|
|
165
|
-
|
|
166
|
-
def is_proxy_count_valid(
|
|
167
|
-
self, ip_list: List[IpAddressType], strict: bool = False
|
|
168
|
-
) -> bool:
|
|
169
|
-
"""
|
|
170
|
-
Checks if the proxy count is valid
|
|
171
|
-
@param ip_list: list of ip addresses
|
|
172
|
-
@param strict: if True, we must have exactly proxy_count proxies, including `0` proxies
|
|
173
|
-
@return: True if the proxy count is valid, False otherwise
|
|
174
|
-
"""
|
|
175
|
-
# No proxy count check is required
|
|
176
|
-
if self.proxy_count is None:
|
|
177
|
-
return True
|
|
178
|
-
|
|
179
|
-
ip_count: int = len(ip_list)
|
|
180
|
-
|
|
181
|
-
if strict:
|
|
182
|
-
# our first proxy takes the last ip address and treats it as client ip
|
|
183
|
-
return self.proxy_count == ip_count - 1
|
|
184
|
-
|
|
185
|
-
# the client could have gone through their own proxy and included extra ips
|
|
186
|
-
# client could be sending in the header: X-Forwarded-For: <fake_ip>, <client_ip>
|
|
187
|
-
return ip_count - 1 > self.proxy_count
|
|
188
|
-
|
|
189
|
-
def is_proxy_trusted_list_valid(
|
|
190
|
-
self,
|
|
191
|
-
ip_list: List[IpAddressType],
|
|
192
|
-
strict: bool = False,
|
|
193
|
-
) -> bool:
|
|
194
|
-
"""
|
|
195
|
-
Checks if the proxy list is valid (all proxies are in the proxy_list)
|
|
196
|
-
@param ip_list: list of ip addresses
|
|
197
|
-
@param strict: if True, we must have exactly proxy_count proxies
|
|
198
|
-
@return: client's best match ip address or False
|
|
199
|
-
"""
|
|
200
|
-
if not self.proxy_list:
|
|
201
|
-
return True
|
|
202
|
-
|
|
203
|
-
ip_count = len(ip_list)
|
|
204
|
-
proxy_list_count = len(self.proxy_list)
|
|
205
|
-
|
|
206
|
-
# in strict mode, total ip count must be 1 more than proxy count
|
|
207
|
-
if strict and ip_count - 1 != proxy_list_count:
|
|
208
|
-
return False
|
|
209
|
-
|
|
210
|
-
# total ip count (client + proxies) must be more than proxy count
|
|
211
|
-
if ip_count - 1 < proxy_list_count:
|
|
212
|
-
return False
|
|
213
|
-
|
|
214
|
-
# start from the end, slice the incoming ip list to the same length as the trusted proxy list
|
|
215
|
-
ip_list_slice = ip_list[-proxy_list_count:]
|
|
216
|
-
for index, value in enumerate(ip_list_slice):
|
|
217
|
-
if not str(value).startswith(self.proxy_list[index]):
|
|
218
|
-
return False
|
|
219
|
-
|
|
220
|
-
return True
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class IpWare(IpWareMeta, IpWareProxy, IpWareIpAddress):
|
|
224
|
-
"""
|
|
225
|
-
A class that makes best effort to determine the client's IP address.
|
|
226
|
-
"""
|
|
227
|
-
|
|
228
|
-
def __init__(
|
|
229
|
-
self,
|
|
230
|
-
precedence: Optional[Tuple[str, ...]] = None,
|
|
231
|
-
leftmost: bool = True,
|
|
232
|
-
proxy_count: Optional[int] = None,
|
|
233
|
-
proxy_list: Optional[List[str]] = None,
|
|
234
|
-
) -> None:
|
|
235
|
-
IpWareMeta.__init__(self, precedence, leftmost)
|
|
236
|
-
IpWareProxy.__init__(self, proxy_count, proxy_list)
|
|
237
|
-
|
|
238
|
-
def get_meta_value(self, meta: Dict[str, str], key: str) -> str:
|
|
239
|
-
"""
|
|
240
|
-
Given a key, it returns a cleaned up version of the value
|
|
241
|
-
@param key: the key to lookup
|
|
242
|
-
@return: the value of the key or empty string
|
|
243
|
-
"""
|
|
244
|
-
meta = meta or {}
|
|
245
|
-
return meta.get(key, meta.get(key.replace("_", "-"), "")).strip()
|
|
246
|
-
|
|
247
|
-
def get_meta_values(self, meta: Dict[str, str]) -> List[str]:
|
|
248
|
-
"""
|
|
249
|
-
Given a list of keys, it returns a list of cleaned up values
|
|
250
|
-
@return: a list of values
|
|
251
|
-
"""
|
|
252
|
-
meta_list: List[str] = []
|
|
253
|
-
for key in self.precedence:
|
|
254
|
-
value = self.get_meta_value(meta, key).strip()
|
|
255
|
-
if value:
|
|
256
|
-
meta_list.append(value)
|
|
257
|
-
|
|
258
|
-
return meta_list
|
|
259
|
-
|
|
260
|
-
def get_client_ip(
|
|
261
|
-
self,
|
|
262
|
-
meta: Dict[str, str],
|
|
263
|
-
strict: bool = False,
|
|
264
|
-
) -> Tuple[OptionalIpAddressType, bool]:
|
|
265
|
-
"""
|
|
266
|
-
Returns the client's IP address.
|
|
267
|
-
"""
|
|
268
|
-
|
|
269
|
-
loopback_list: List[IpAddressType] = []
|
|
270
|
-
private_list: List[OptionalIpAddressType] = []
|
|
271
|
-
|
|
272
|
-
for ip_str in self.get_meta_values(meta):
|
|
273
|
-
|
|
274
|
-
ip_list = self.get_ips_from_string(ip_str)
|
|
275
|
-
if not ip_list:
|
|
276
|
-
continue
|
|
277
|
-
|
|
278
|
-
proxy_count_validated = self.is_proxy_count_valid(ip_list, strict)
|
|
279
|
-
if not proxy_count_validated:
|
|
280
|
-
continue
|
|
281
|
-
|
|
282
|
-
proxy_list_validated = self.is_proxy_trusted_list_valid(ip_list, strict)
|
|
283
|
-
if not proxy_list_validated:
|
|
284
|
-
continue
|
|
285
|
-
|
|
286
|
-
client_ip, trusted_route = self.get_best_ip(
|
|
287
|
-
ip_list, proxy_count_validated, proxy_list_validated
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
# we found a global ip, return it
|
|
291
|
-
if client_ip is not None and client_ip.is_global:
|
|
292
|
-
return client_ip, trusted_route
|
|
293
|
-
|
|
294
|
-
# we found a private ip, save it
|
|
295
|
-
if client_ip is not None and client_ip.is_loopback:
|
|
296
|
-
loopback_list.append(client_ip)
|
|
297
|
-
else:
|
|
298
|
-
# if not global (public) or loopback (local), we treat it asd private
|
|
299
|
-
private_list.append(client_ip)
|
|
300
|
-
|
|
301
|
-
# we have not been able to locate a global ip
|
|
302
|
-
# it could be the server is running on the intranet
|
|
303
|
-
# we will return the first private ip we found
|
|
304
|
-
if private_list:
|
|
305
|
-
return private_list[0], False
|
|
306
|
-
|
|
307
|
-
# we have not been able to locate a global ip, nor a private ip
|
|
308
|
-
# it could be the server is running on a loopback address serving local requests
|
|
309
|
-
if loopback_list:
|
|
310
|
-
return loopback_list[0], False
|
|
311
|
-
|
|
312
|
-
# we were unable to find any ip address
|
|
313
|
-
return None, False
|
|
314
|
-
|
|
315
|
-
def get_best_ip(
|
|
316
|
-
self,
|
|
317
|
-
ip_list: List[IpAddressType],
|
|
318
|
-
proxy_count_validated: bool = True,
|
|
319
|
-
proxy_list_validated: bool = True,
|
|
320
|
-
) -> Tuple[OptionalIpAddressType, bool]:
|
|
321
|
-
"""
|
|
322
|
-
Returns the best possible ip for the client.
|
|
323
|
-
"""
|
|
324
|
-
|
|
325
|
-
if not ip_list:
|
|
326
|
-
logger.warning("Invalid ip list provided.")
|
|
327
|
-
return None, False
|
|
328
|
-
|
|
329
|
-
# the incoming ips match our trusted proxy list
|
|
330
|
-
if len(self.proxy_list) > 0 and proxy_list_validated:
|
|
331
|
-
best_client_ip_index = len(self.proxy_list) + 1
|
|
332
|
-
best_client_ip = ip_list[-best_client_ip_index]
|
|
333
|
-
return best_client_ip, True
|
|
334
|
-
|
|
335
|
-
# the incoming ips match our proxy count
|
|
336
|
-
if (
|
|
337
|
-
self.proxy_count is not None
|
|
338
|
-
and self.proxy_count > 0
|
|
339
|
-
and proxy_count_validated
|
|
340
|
-
):
|
|
341
|
-
best_client_ip_index = self.proxy_count + 1
|
|
342
|
-
best_client_ip = ip_list[-best_client_ip_index]
|
|
343
|
-
return best_client_ip, True
|
|
344
|
-
|
|
345
|
-
# we don't track proxy related info, so we just return the first ip
|
|
346
|
-
return ip_list[0], False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|