diff --git a/.jules/sentinel.md b/.jules/sentinel.md index d3e2b0a..0f15379 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -75,3 +75,7 @@ **Vulnerability:** Python's `ipaddress.ip_address()` function accepts both `str` and `bytes`. When passing an extremely large bytes object (e.g., `b"A" * 10**8`), the module can take several seconds to raise a `ValueError` due to inefficient internal parsing logic, leading to a CPU exhaustion Denial of Service (DoS). **Learning:** Checking the length of `str` inputs before passing them to `ipaddress.ip_address()` is not sufficient to prevent DoS, as an attacker could pass a massive `bytes` object if the function accepts polymorphic types. **Prevention:** Always enforce strict length limits (e.g., <= 100 characters/bytes) on *both* `str` and `bytes` inputs before attempting to parse them using the `ipaddress` module. Use `isinstance(ip, (str, bytes))` and check `len()`. +## 2025-05-12 - Prevent SSRF Bypass via ISATAP Tunneling Addresses +**Vulnerability:** The Python `ipaddress` module evaluates ISATAP addresses (e.g., `2001:db8::5efe:127.0.0.1`) as `is_global = True` and lacks a native `is_isatap` property. This can allow attackers to bypass SSRF protections by encapsulating restricted IPv4 addresses (like `127.0.0.1` or `192.168.1.1`) inside an ISATAP IPv6 address. +**Learning:** Python's standard `ipaddress` module does not intrinsically unwrap or identify ISATAP encapsulated payloads. We must manually inspect the 32-bit ISATAP identifier and unwrap it. +**Prevention:** Extract the 32-bit ISATAP identifier using `(ip_int >> 32) & 0xFFFFFFFF`. If it matches `0x00005efe` or `0x02005efe`, extract the underlying IPv4 address using `ip_int & 0xFFFFFFFF` and validate it against the SSRF rules. diff --git a/test_testping1.py b/test_testping1.py index 9f39c61..c8a3951 100644 --- a/test_testping1.py +++ b/test_testping1.py @@ -281,6 +281,16 @@ def test_is_reachable_ssrf_bypass_teredo(self, mock_call): self.assertIn("IP address not allowed for scanning", log.output[0]) mock_call.assert_not_called() + @patch('testping1.subprocess.call') + def test_is_reachable_ssrf_bypass_isatap(self, mock_call): + """Test is_reachable prevents SSRF bypass via ISATAP tunneling addresses.""" + ssrf_ips = ['2000::5efe:127.0.0.1', '2000::0200:5efe:127.0.0.1'] + for ip in ssrf_ips: + with self.assertLogs(level='ERROR') as log: + self.assertFalse(is_reachable(ip)) + self.assertIn("IP address not allowed for scanning", log.output[0]) + mock_call.assert_not_called() + @patch('testping1.subprocess.call') def test_is_reachable_ssrf_bypass_nat64_and_compat(self, mock_call): """Test is_reachable prevents SSRF bypass via NAT64 and IPv4-compatible addresses.""" diff --git a/testping1.py b/testping1.py index aa0ff6e..d86ecdc 100644 --- a/testping1.py +++ b/testping1.py @@ -136,6 +136,11 @@ def is_reachable(ip, timeout=1): unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) elif ip_int < 2**32 and ip_int not in (0, 1): # IPv4-compatible ::w.x.y.z unwrapped = ipaddress.IPv4Address(ip_int) + else: + # 🛡️ Sentinel: Prevent SSRF bypass via ISATAP tunneling addresses + isatap_id = (ip_int >> 32) & 0xFFFFFFFF + if isatap_id in (0x00005efe, 0x02005efe): + unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) if unwrapped is not None: is_blocked = not unwrapped.is_global or unwrapped.is_multicast