Skip to content

Commit 045b33d

Browse files
akushonkamencodelion
authored andcommitted
fix(readurls): block CGNAT/shared and multicast ranges in SSRF guard
- Switch the guard from a private-range deny-list to a global-routability allow-list (not is_global), which additionally blocks CGNAT/shared space (100.64.0.0/10, e.g. Tailscale) that is neither private nor global - Keep an explicit multicast check (ipaddress marks globally-routable multicast ranges as is_global) - Fail closed when resolution returns no addresses - Document the residual DNS rebinding (TOCTOU) limitation in code and README - Add 3 tests: CGNAT blocked, multicast blocked, empty resolution blocked
1 parent 086c678 commit 045b33d

3 files changed

Lines changed: 42 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ optillm
171171

172172
⚠️ **Security Note**: Only enable this on trusted networks where internal URL fetching is actually required.
173173

174+
**Known limitation (DNS rebinding)**: the guard validates DNS resolution before each request, but the underlying HTTP client performs a second resolution when it opens the connection, so a domain under attacker control could rebind to an internal address between the two lookups. For high-security deployments, additionally restrict the network egress of the optillm process (firewall or service-mesh policy) instead of relying on this check alone.
175+
174176
## Implemented techniques
175177

176178
| Approach | Slug | Description |

‎optillm/plugins/readurls_plugin.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,16 @@
1919
def _is_blocked_host(url: str) -> bool:
2020
"""
2121
SSRF guard: return True when the URL does not point at a globally
22-
reachable public address (private, loopback, link-local, reserved,
23-
multicast or unspecified), or when the host cannot be resolved
24-
(fail-closed).
22+
reachable public address (loopback, private, link-local, reserved,
23+
CGNAT/shared space, multicast or unspecified), or when the host cannot
24+
be resolved (fail-closed).
25+
26+
Known limitation (DNS rebinding): the host is resolved here and the HTTP
27+
client performs a second resolution when it opens the connection, so a
28+
domain under attacker control could rebind to an internal address between
29+
the two lookups. For high-security deployments, additionally restrict the
30+
network egress of the optillm process instead of relying on this check
31+
alone.
2532
"""
2633
try:
2734
hostname = urlparse(url).hostname
@@ -30,10 +37,17 @@ def _is_blocked_host(url: str) -> bool:
3037

3138
# Resolve all addresses for the host and block the URL if any of
3239
# them is non-public
33-
for addr_info in socket.getaddrinfo(hostname, None):
40+
addr_infos = socket.getaddrinfo(hostname, None)
41+
if not addr_infos:
42+
# No resolution results: fail closed
43+
return True
44+
for addr_info in addr_infos:
3445
ip = ipaddress.ip_address(addr_info[4][0])
35-
if (ip.is_private or ip.is_loopback or ip.is_link_local or
36-
ip.is_reserved or ip.is_multicast or ip.is_unspecified):
46+
# ipaddress marks globally-routable multicast ranges as global,
47+
# so multicast must be blocked explicitly. CGNAT/shared space
48+
# (100.64.0.0/10, e.g. Tailscale) is not in the classic private
49+
# ranges but is not globally routable either.
50+
if ip.is_multicast or not ip.is_global:
3751
return True
3852
return False
3953
except (OSError, ValueError, UnicodeError):

‎tests/test_readurls_ssrf.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ def test_allows_public_resolved_host(self):
101101
return_value=_addrinfo('93.184.216.34')):
102102
self.assertFalse(_is_blocked_host('https://example.com/article'))
103103

104+
def test_blocks_cgnat_shared_range(self):
105+
"""100.64.0.0/10 (carrier-grade NAT, e.g. Tailscale) is not globally
106+
routable even though it is outside the classic private ranges."""
107+
from optillm.plugins.readurls_plugin import _is_blocked_host
108+
self.assertTrue(_is_blocked_host('http://100.64.0.1/'))
109+
self.assertTrue(_is_blocked_host('http://100.100.100.100/'))
110+
111+
def test_blocks_multicast(self):
112+
"""Multicast ranges are marked global by ipaddress but must be blocked."""
113+
from optillm.plugins.readurls_plugin import _is_blocked_host
114+
self.assertTrue(_is_blocked_host('http://224.0.0.1/'))
115+
self.assertTrue(_is_blocked_host('http://[ff02::1]/'))
116+
117+
def test_blocks_empty_resolution(self):
118+
"""An empty resolution result must fail closed, not be allowed."""
119+
from optillm.plugins.readurls_plugin import _is_blocked_host
120+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
121+
return_value=[]):
122+
self.assertTrue(_is_blocked_host('http://example.com/'))
123+
104124

105125
class TestFetchWebpageContentGuard(unittest.TestCase):
106126
"""Behavior tests: fetch_webpage_content refuses non-public URLs."""

0 commit comments

Comments
 (0)