python-ipware 4.1.0__tar.gz → 4.1.1__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.
Files changed (27) hide show
  1. {python_ipware-4.1.0 → python_ipware-4.1.1}/CHANGELOG.md +8 -0
  2. {python_ipware-4.1.0 → python_ipware-4.1.1}/PKG-INFO +1 -1
  3. python_ipware-4.1.1/python_ipware/__version__.py +1 -0
  4. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/modern/parsers.py +22 -2
  5. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/tests_router.py +72 -0
  6. python_ipware-4.1.0/python_ipware/__version__.py +0 -1
  7. {python_ipware-4.1.0 → python_ipware-4.1.1}/.gitignore +0 -0
  8. {python_ipware-4.1.0 → python_ipware-4.1.1}/LICENSE +0 -0
  9. {python_ipware-4.1.0 → python_ipware-4.1.1}/README.md +0 -0
  10. {python_ipware-4.1.0 → python_ipware-4.1.1}/SECURITY.md +0 -0
  11. {python_ipware-4.1.0 → python_ipware-4.1.1}/pyproject.toml +0 -0
  12. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/__init__.py +0 -0
  13. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/legacy/README.md +0 -0
  14. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/legacy/__init__.py +0 -0
  15. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/legacy/engine.py +0 -0
  16. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/modern/__init__.py +0 -0
  17. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/modern/defaults.py +0 -0
  18. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/modern/engine.py +0 -0
  19. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/py.typed +0 -0
  20. {python_ipware-4.1.0 → python_ipware-4.1.1}/python_ipware/router.py +0 -0
  21. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/__init__.py +0 -0
  22. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/legacy/__init__.py +0 -0
  23. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/legacy/run_against_legacy.py +0 -0
  24. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/legacy/tests_ipv4.py +0 -0
  25. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/legacy/tests_ipv6.py +0 -0
  26. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/tests_modern_config.py +0 -0
  27. {python_ipware-4.1.0 → python_ipware-4.1.1}/tests/tests_modern_matrix.py +0 -0
@@ -1,3 +1,11 @@
1
+ ## 4.1.1
2
+
3
+ Reported privately by David Gilman.
4
+
5
+ Harden (modern engine only; legacy is unchanged):
6
+ - A port longer than five significant digits is rejected, same as port `99999`, instead of calling `int()` on an unbounded digit string. A client-controlled header of about 4.3 KB could otherwise make `get_client_ip()` raise `ValueError` (Python's `int` string-digit limit). Zero-padded ports such as `000080` are still accepted.
7
+ - IPv6 zone ids longer than 255 characters are rejected. RFC 4007 sets no maximum; 255 matches the longest common OS interface-name limit so a multi-kilobyte scope is not returned, logged, or stored. The legacy engine still accepts any length.
8
+
1
9
  ## 4.1.0
2
10
 
3
11
  Best match (modern engine only; legacy is unchanged). Results can differ from 4.0.0 on well-formed input,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-ipware
3
- Version: 4.1.0
3
+ Version: 4.1.1
4
4
  Summary: A Python package to retrieve a client's real IP address.
5
5
  Project-URL: Homepage, https://github.com/un33k/python-ipware
6
6
  Project-URL: Documentation, https://github.com/un33k/python-ipware#readme
@@ -0,0 +1 @@
1
+ __version__ = "4.1.1"
@@ -20,6 +20,11 @@ _NAT64_LOCAL = ipaddress.IPv6Network("64:ff9b:1::/48")
20
20
  _IPV4_MAPPED = ipaddress.IPv6Network("::ffff:0:0/96")
21
21
  # RFC 1122 "this network": never a valid source address.
22
22
  _THIS_NETWORK = ipaddress.IPv4Network("0.0.0.0/8")
23
+ # RFC 4007 sets no maximum zone-id length. RFC 9844 §6 asks implementations to
24
+ # apply one, typically the OS interface-name limit (15 on Linux, up to 255 on
25
+ # Windows). 255 stops a multi-kilobyte scope from being logged or stored
26
+ # without rejecting a real interface name.
27
+ _MAX_ZONE_ID_LEN = 255
23
28
 
24
29
  # How good an address is as a client IP; higher wins. REJECT is never returned.
25
30
  TIER_REJECT = 0
@@ -65,7 +70,22 @@ def ip_tier(ip: IpAddressType) -> int:
65
70
 
66
71
  def _is_port(value: str) -> bool:
67
72
  # isascii() guards against Unicode digits such as "²" that isdigit() accepts.
68
- return value.isascii() and value.isdigit() and int(value) <= 65535
73
+ if not (value.isascii() and value.isdigit()):
74
+ return False
75
+ # Strip leading zeros, then bound the length before int(): a long digit
76
+ # string would otherwise exceed sys.get_int_max_str_digits() and raise.
77
+ digits = value.lstrip("0")
78
+ return len(digits) <= 5 and int(digits or "0") <= 65535
79
+
80
+
81
+ def _zone_id_ok(value: str) -> bool:
82
+ """False when an IPv6 zone id is longer than ``_MAX_ZONE_ID_LEN``.
83
+
84
+ A missing ``%`` is fine. An empty zone (``fe80::1%``) is left to
85
+ ``ipaddress``, which already rejects it.
86
+ """
87
+ _host, sep, zone = value.partition("%")
88
+ return not sep or len(zone) <= _MAX_ZONE_ID_LEN
69
89
 
70
90
 
71
91
  def strip_port(value: str) -> str:
@@ -155,7 +175,7 @@ def parse_ip(value: Optional[str]) -> Optional[IpAddressType]:
155
175
  well-known prefix (``64:ff9b::a.b.c.d``) to the embedded IPv4 client.
156
176
  """
157
177
  cleaned = clean_ip(value)
158
- if not cleaned:
178
+ if not cleaned or not _zone_id_ok(cleaned):
159
179
  return None
160
180
  try:
161
181
  return unwrap_ipv4(ipaddress.ip_address(cleaned))
@@ -223,6 +223,78 @@ class TestModernMalformedRejected(unittest.TestCase):
223
223
  self.assertEqual(str(ip), expected)
224
224
 
225
225
 
226
+ class TestLongPort(unittest.TestCase):
227
+ """A huge port must be rejected, never raise from int()."""
228
+
229
+ def test_long_port_is_rejected_not_raised(self):
230
+ port = "1" * 5000
231
+ metas = (
232
+ {"HTTP_X_FORWARDED_FOR": f"1.2.3.4:{port}"},
233
+ {"HTTP_X_FORWARDED_FOR": f"[2001:db8::1]:{port}"},
234
+ {"HTTP_X_FORWARDED_FOR": f":{port}"},
235
+ {"HTTP_X_FORWARDED_FOR": f"junk:{port}"},
236
+ {"HTTP_CLIENT_IP": f"1.2.3.4:{port}"},
237
+ {"HTTP_FORWARDED": f'for="1.2.3.4:{port}"'},
238
+ {"HTTP_FORWARDED": f'for="[2001:db8::1]:{port}"'},
239
+ {"REMOTE_ADDR": f"1.2.3.4:{port}"},
240
+ {"x-forwarded-for": f"1.2.3.4:{port}"},
241
+ )
242
+ for meta in metas:
243
+ for strict in (False, True):
244
+ with self.subTest(meta=list(meta), strict=strict):
245
+ self.assertEqual(IpWare().get_client_ip(meta, strict), (None, False))
246
+
247
+ def test_long_port_on_a_later_hop_is_skipped(self):
248
+ port = "1" * 5000
249
+ meta = {"HTTP_X_FORWARDED_FOR": f"8.8.8.8, 1.2.3.4:{port}"}
250
+ ip, trusted = IpWare().get_client_ip(meta)
251
+ self.assertEqual((str(ip), trusted), ("8.8.8.8", False))
252
+ self.assertEqual(IpWare().get_client_ip(meta, strict=True), (None, False))
253
+
254
+ def test_long_port_with_proxy_options_does_not_raise(self):
255
+ meta = {"HTTP_X_FORWARDED_FOR": "1.2.3.4:" + "1" * 5000}
256
+ self.assertEqual(IpWare(proxy_count=1).get_client_ip(meta), (None, False))
257
+ self.assertEqual(
258
+ IpWare(proxy_list=["10.0.0.1"]).get_client_ip(meta),
259
+ (None, False),
260
+ )
261
+
262
+ def test_long_zero_padded_port_is_accepted(self):
263
+ meta = {"HTTP_X_FORWARDED_FOR": "1.2.3.4:" + "0" * 5000 + "80"}
264
+ ip, trusted = IpWare().get_client_ip(meta)
265
+ self.assertEqual(str(ip), "1.2.3.4")
266
+ self.assertFalse(trusted)
267
+
268
+ def test_zero_padded_and_boundary_ports(self):
269
+ accepted = ("1.2.3.4:000080", "1.2.3.4:65535", "1.2.3.4:0", "[2001:db8::1]:000443")
270
+ for value in accepted:
271
+ with self.subTest(value=value):
272
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": value})
273
+ self.assertIsNotNone(ip)
274
+ rejected = ("1.2.3.4:65536", "1.2.3.4:00065536", "1.2.3.4:" + "9" * 4300)
275
+ for value in rejected:
276
+ with self.subTest(value=value[:24]):
277
+ self.assertEqual(IpWare().get_client_ip({"REMOTE_ADDR": value}), (None, False))
278
+
279
+
280
+ class TestZoneIdLength(unittest.TestCase):
281
+ def test_ordinary_zone_id_is_kept(self):
282
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": "fe80::1%eth0"})
283
+ self.assertEqual(str(ip), "fe80::1%eth0")
284
+
285
+ def test_zone_id_at_cap_is_kept(self):
286
+ zone = "z" * 255
287
+ ip, _ = IpWare().get_client_ip({"REMOTE_ADDR": f"fe80::1%{zone}"})
288
+ self.assertEqual(str(ip), f"fe80::1%{zone}")
289
+
290
+ def test_oversized_zone_id_is_rejected(self):
291
+ zone = "x" * 5000
292
+ meta = {"HTTP_X_FORWARDED_FOR": f"2606:4700::1111%{zone}"}
293
+ self.assertEqual(IpWare().get_client_ip(meta), (None, False))
294
+ bracketed = {"REMOTE_ADDR": f"[fe80::1%{zone}]:80"}
295
+ self.assertEqual(IpWare().get_client_ip(bracketed), (None, False))
296
+
297
+
226
298
  class TestModernMetaHardening(unittest.TestCase):
227
299
  def test_non_string_values_are_ignored(self):
228
300
  for bad in (None, b"1.2.3.4", ["1.2.3.4"], 42):
@@ -1 +0,0 @@
1
- __version__ = "4.1.0"
File without changes
File without changes
File without changes
File without changes