diff --git a/.jules/sentinel.md b/.jules/sentinel.md index feb373d..df14377 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **Vulnerability:** The `_is_safe_ip` function relied primarily on `is_private` and `is_global` properties of Python's `ipaddress` module to prevent SSRF loopback connections. While these often cover `127.0.0.1` and `::1`, edge cases and alternative loopback addresses may bypass these checks depending on OS/network configurations. **Learning:** Defense-in-depth is essential when validating IPs. Relying solely on `is_private` or `is_global` without explicitly checking `is_loopback` creates potential edge cases where loopback traffic might not be caught, increasing SSRF risk. **Prevention:** Explicitly check for `is_loopback` along with `is_unspecified` and `is_private` to ensure comprehensive outbound SSRF filtering. + +## 2025-04-07 - Add explicit link-local IP check to prevent SSRF bypass +**Vulnerability:** The `_is_safe_ip` function lacked an explicit check for link-local addresses (like `169.254.169.254`, commonly used for cloud metadata services), implicitly relying on `is_global` or `is_private`. +**Learning:** OS-specific network stacks might unexpectedly route link-local addresses or fail to properly classify them as non-global or private. This could allow attackers to bypass SSRF protections and access sensitive internal metadata endpoints. +**Prevention:** Defense-in-depth requires explicitly blocking `ip.is_link_local` alongside `is_loopback`, `is_unspecified`, and `is_private` to ensure complete outbound SSRF filtering. diff --git a/main.py b/main.py index ea44b9a..4c2676f 100644 --- a/main.py +++ b/main.py @@ -1075,6 +1075,8 @@ def _is_safe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: return False if ip.is_loopback: return False + if ip.is_link_local: + return False if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: return _is_safe_ip(ip.ipv4_mapped) if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK: diff --git a/tests/test_ssrf_link_local.py b/tests/test_ssrf_link_local.py new file mode 100644 index 0000000..fcef4f2 --- /dev/null +++ b/tests/test_ssrf_link_local.py @@ -0,0 +1,28 @@ +import os +import socket +import sys +import unittest +from unittest.mock import patch + +# Add root to path to import main +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import main + +class TestSSRFLinkLocal(unittest.TestCase): + def test_domain_resolving_to_link_local_ip(self): + """ + Test that a domain resolving to a link-local IP (169.254.169.254) is blocked. + """ + with patch("socket.getaddrinfo") as mock_getaddrinfo: + # Simulate resolving to AWS metadata service IP + mock_getaddrinfo.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 443)) + ] + + url = "https://metadata.example.com/config.json" + result = main.validate_folder_url(url) + self.assertFalse(result, "Should block domain resolving to link-local IP") + +if __name__ == "__main__": + unittest.main()