Skip to content

Commit 4088efc

Browse files
akushonkamencodelion
authored andcommitted
fix(readurls): block SSRF destinations via stdlib host guard
fetch_webpage_content() requested any URL extracted from the prompt with no restriction on the destination host, letting callers steer the server into private, loopback or link-local addresses (e.g. the cloud metadata IP 169.254.169.254). - Resolve the host with socket.getaddrinfo and refuse URLs where any resolved address is private, loopback, link-local, reserved, multicast or unspecified; fail closed on unresolvable/malformed hosts - Follow redirects manually (max 5) and re-run the guard on every hop so a public URL cannot redirect into an internal address - Operators can explicitly opt out with --readurls-allow-internal / OPTILLM_READURLS_ALLOW_INTERNAL for trusted internal fetches - Pure stdlib (ipaddress, socket); no new dependencies Fixes #323
1 parent e4ad199 commit 4088efc

4 files changed

Lines changed: 310 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,23 @@ optillm
154154

155155
⚠️ **Security Note**: Disabling SSL verification is insecure and should only be used in development. For production environments with custom CAs, use `--ssl-cert-path` instead. See [SSL_CONFIGURATION.md](SSL_CONFIGURATION.md) for details.
156156

157+
## 🔒 URL Fetch Security (readurls plugin)
158+
159+
The `readurls` plugin fetches URLs found in prompts and blocks requests to non-public destinations (private, loopback, link-local, reserved addresses such as the cloud metadata IP `169.254.169.254`) to prevent SSRF. Every redirect hop is re-validated and unresolvable hosts are rejected.
160+
161+
If your deployment intentionally needs to read internal documentation pages, you can explicitly opt out:
162+
163+
```bash
164+
# Command line
165+
optillm --readurls-allow-internal
166+
167+
# Environment variable
168+
export OPTILLM_READURLS_ALLOW_INTERNAL=true
169+
optillm
170+
```
171+
172+
⚠️ **Security Note**: Only enable this on trusted networks where internal URL fetching is actually required.
173+
157174
## Implemented techniques
158175

159176
| Approach | Slug | Description |

‎optillm/plugins/readurls_plugin.py‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,44 @@
22
from typing import Tuple, List, Optional
33
import requests
44
import os
5+
import ipaddress
6+
import socket
57
from bs4 import BeautifulSoup
6-
from urllib.parse import urlparse
8+
from urllib.parse import urlparse, urljoin
79
from optillm import __version__, server_config
810

911
SLUG = "readurls"
1012

13+
# Redirect statuses followed manually so each hop can be re-checked by the
14+
# SSRF guard before the next request is issued
15+
REDIRECT_STATUS_CODES = frozenset((301, 302, 303, 307, 308))
16+
MAX_REDIRECTS = 5
17+
18+
19+
def _is_blocked_host(url: str) -> bool:
20+
"""
21+
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).
25+
"""
26+
try:
27+
hostname = urlparse(url).hostname
28+
if not hostname:
29+
return True
30+
31+
# Resolve all addresses for the host and block the URL if any of
32+
# them is non-public
33+
for addr_info in socket.getaddrinfo(hostname, None):
34+
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):
37+
return True
38+
return False
39+
except (OSError, ValueError, UnicodeError):
40+
# Unresolvable or malformed hosts are blocked
41+
return True
42+
1143
def extract_urls(text: str) -> List[str]:
1244
# Updated regex pattern to be more precise
1345
url_pattern = re.compile(r'https?://[^\s\'"]+')
@@ -44,7 +76,27 @@ def fetch_webpage_content(url: str, max_length: int = 100000, verify_ssl: Option
4476
else:
4577
verify = True
4678

47-
response = requests.get(url, headers=headers, timeout=10, verify=verify)
79+
# SSRF guard: resolve the host and refuse non-public destinations
80+
# before fetching. Redirects are followed manually so every hop is
81+
# re-checked, closing the bypass where a public URL redirects to an
82+
# internal address. Operators can explicitly allow internal fetches
83+
# via server_config['readurls_allow_internal'].
84+
allow_internal = server_config.get('readurls_allow_internal', False)
85+
current_url = url
86+
response = None
87+
for _ in range(MAX_REDIRECTS + 1):
88+
if not allow_internal and _is_blocked_host(current_url):
89+
return "Error fetching content: blocked non-public URL"
90+
response = requests.get(current_url, headers=headers, timeout=10, verify=verify, allow_redirects=False)
91+
if response.status_code not in REDIRECT_STATUS_CODES:
92+
break
93+
location = response.headers.get('Location')
94+
if not location:
95+
break
96+
current_url = urljoin(current_url, location)
97+
98+
if response.status_code in REDIRECT_STATUS_CODES:
99+
return "Error fetching content: too many redirects"
48100
response.raise_for_status()
49101

50102
# Make a soup

‎optillm/server.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ def count_reasoning_tokens(text: str, tokenizer=None) -> int:
195195
'log': 'info',
196196
'ssl_verify': True,
197197
'ssl_cert_path': '',
198+
'readurls_allow_internal': False,
198199
}
199200

200201
# List of known approaches
@@ -1094,6 +1095,7 @@ def parse_args():
10941095
("--plugins-dir", "OPTILLM_PLUGINS_DIR", str, "", "Path to the plugins directory"),
10951096
("--log-conversations", "OPTILLM_LOG_CONVERSATIONS", bool, False, "Enable conversation logging with full metadata"),
10961097
("--conversation-log-dir", "OPTILLM_CONVERSATION_LOG_DIR", str, str(Path.home() / ".optillm" / "conversations"), "Directory to save conversation logs"),
1098+
("--readurls-allow-internal", "OPTILLM_READURLS_ALLOW_INTERNAL", bool, False, "Allow the readurls plugin to fetch non-public/internal URLs (disables SSRF protection)"),
10971099
]
10981100

10991101
for arg, env, type_, default, help_text, *extra in args_env:

‎tests/test_readurls_ssrf.py‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""
2+
Unit tests for SSRF protection in the readurls plugin.
3+
4+
Tests verify that the readurls plugin refuses to fetch non-public
5+
destinations (private, loopback, link-local, reserved, multicast,
6+
unspecified addresses), re-validates every redirect hop, fails closed on
7+
DNS errors, and that operators can explicitly opt out via
8+
server_config['readurls_allow_internal'].
9+
10+
All tests are fully offline: DNS resolution is either exercised only on
11+
literal IPs (which libc resolves without network access) or mocked.
12+
"""
13+
14+
import unittest
15+
from unittest.mock import MagicMock, patch
16+
import socket
17+
import sys
18+
import os
19+
20+
# Add parent directory to path to import optillm modules
21+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22+
23+
from optillm import server_config
24+
25+
26+
METADATA_URL = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
27+
28+
29+
def _addrinfo(ip):
30+
"""Build a getaddrinfo-shaped result for a single IP string."""
31+
af = socket.AF_INET6 if ':' in ip else socket.AF_INET
32+
return [(af, socket.SOCK_STREAM, 6, '', (ip, 0))]
33+
34+
35+
def _getaddrinfo_by_host(host_to_ip):
36+
"""Return a getaddrinfo stand-in: known hosts resolve via the mapping,
37+
anything else (e.g. literal IPs) resolves to itself."""
38+
def fake_getaddrinfo(host, *args, **kwargs):
39+
return _addrinfo(host_to_ip.get(host, host))
40+
return fake_getaddrinfo
41+
42+
43+
class TestIsBlockedHost(unittest.TestCase):
44+
"""Unit tests for the _is_blocked_host guard."""
45+
46+
def test_blocks_loopback_ipv4(self):
47+
from optillm.plugins.readurls_plugin import _is_blocked_host
48+
self.assertTrue(_is_blocked_host('http://127.0.0.1/index.html'))
49+
50+
def test_blocks_metadata_ip(self):
51+
from optillm.plugins.readurls_plugin import _is_blocked_host
52+
self.assertTrue(_is_blocked_host(METADATA_URL))
53+
54+
def test_blocks_private_10(self):
55+
from optillm.plugins.readurls_plugin import _is_blocked_host
56+
self.assertTrue(_is_blocked_host('http://10.0.0.1/'))
57+
58+
def test_blocks_private_192_168(self):
59+
from optillm.plugins.readurls_plugin import _is_blocked_host
60+
self.assertTrue(_is_blocked_host('http://192.168.1.1/'))
61+
62+
def test_blocks_private_172_16(self):
63+
from optillm.plugins.readurls_plugin import _is_blocked_host
64+
self.assertTrue(_is_blocked_host('http://172.16.0.5/'))
65+
66+
def test_blocks_unspecified(self):
67+
from optillm.plugins.readurls_plugin import _is_blocked_host
68+
self.assertTrue(_is_blocked_host('http://0.0.0.0/'))
69+
70+
def test_blocks_ipv6_loopback(self):
71+
from optillm.plugins.readurls_plugin import _is_blocked_host
72+
self.assertTrue(_is_blocked_host('http://[::1]/'))
73+
74+
def test_blocks_ipv6_link_local(self):
75+
from optillm.plugins.readurls_plugin import _is_blocked_host
76+
self.assertTrue(_is_blocked_host('http://[fe80::1]/'))
77+
78+
def test_blocks_localhost(self):
79+
from optillm.plugins.readurls_plugin import _is_blocked_host
80+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
81+
return_value=_addrinfo('127.0.0.1')):
82+
self.assertTrue(_is_blocked_host('http://localhost/secret'))
83+
84+
def test_blocks_missing_hostname(self):
85+
from optillm.plugins.readurls_plugin import _is_blocked_host
86+
self.assertTrue(_is_blocked_host('http:///path'))
87+
88+
def test_blocks_unresolvable_host(self):
89+
from optillm.plugins.readurls_plugin import _is_blocked_host
90+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
91+
side_effect=socket.gaierror('name resolution failure')):
92+
self.assertTrue(_is_blocked_host('http://internal.example.invalid/'))
93+
94+
def test_allows_public_literal_ip(self):
95+
from optillm.plugins.readurls_plugin import _is_blocked_host
96+
self.assertFalse(_is_blocked_host('http://93.184.216.34/'))
97+
98+
def test_allows_public_resolved_host(self):
99+
from optillm.plugins.readurls_plugin import _is_blocked_host
100+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
101+
return_value=_addrinfo('93.184.216.34')):
102+
self.assertFalse(_is_blocked_host('https://example.com/article'))
103+
104+
105+
class TestFetchWebpageContentGuard(unittest.TestCase):
106+
"""Behavior tests: fetch_webpage_content refuses non-public URLs."""
107+
108+
def setUp(self):
109+
self.original_config = server_config.copy()
110+
111+
def tearDown(self):
112+
server_config.clear()
113+
server_config.update(self.original_config)
114+
115+
@patch('optillm.plugins.readurls_plugin.requests.get')
116+
def test_fetch_blocks_metadata_url(self, mock_requests_get):
117+
"""The fetcher must short-circuit before issuing any request."""
118+
from optillm.plugins.readurls_plugin import fetch_webpage_content
119+
120+
result = fetch_webpage_content(METADATA_URL)
121+
122+
self.assertIn('Error fetching content', result)
123+
self.assertIn('blocked non-public URL', result)
124+
mock_requests_get.assert_not_called()
125+
126+
@patch('optillm.plugins.readurls_plugin.requests.get')
127+
def test_fetch_allows_public_url(self, mock_requests_get):
128+
"""Public URLs are still fetched normally."""
129+
from optillm.plugins.readurls_plugin import fetch_webpage_content
130+
131+
mock_response = MagicMock()
132+
mock_response.content = b'<html><body><p>Public content</p></body></html>'
133+
mock_response.raise_for_status = MagicMock()
134+
mock_requests_get.return_value = mock_response
135+
136+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
137+
return_value=_addrinfo('93.184.216.34')):
138+
result = fetch_webpage_content('https://example.com/article')
139+
140+
self.assertEqual(result, 'Public content')
141+
mock_requests_get.assert_called_once()
142+
143+
@patch('optillm.plugins.readurls_plugin.requests.get')
144+
def test_fetch_blocks_redirect_to_internal(self, mock_requests_get):
145+
"""A public URL redirecting to an internal address must be blocked."""
146+
from optillm.plugins.readurls_plugin import fetch_webpage_content
147+
148+
redirect_response = MagicMock()
149+
redirect_response.status_code = 302
150+
redirect_response.headers = {'Location': METADATA_URL}
151+
redirect_response.raise_for_status = MagicMock()
152+
153+
final_response = MagicMock()
154+
final_response.content = b'<html><body><p>Leaked</p></body></html>'
155+
final_response.raise_for_status = MagicMock()
156+
mock_requests_get.side_effect = [redirect_response, final_response]
157+
158+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
159+
side_effect=_getaddrinfo_by_host({'example.com': '93.184.216.34'})):
160+
result = fetch_webpage_content('https://example.com/redirect')
161+
162+
self.assertIn('blocked non-public URL', result)
163+
self.assertNotIn('Leaked', result)
164+
# Only the initial public request may have been issued
165+
self.assertEqual(mock_requests_get.call_count, 1)
166+
167+
@patch('optillm.plugins.readurls_plugin.requests.get')
168+
def test_fetch_follows_public_redirect(self, mock_requests_get):
169+
"""Redirects between public hosts keep working."""
170+
from optillm.plugins.readurls_plugin import fetch_webpage_content
171+
172+
redirect_response = MagicMock()
173+
redirect_response.status_code = 301
174+
redirect_response.headers = {'Location': 'https://example.com/moved'}
175+
final_response = MagicMock()
176+
final_response.content = b'<html><body><p>Moved content</p></body></html>'
177+
mock_requests_get.side_effect = [redirect_response, final_response]
178+
179+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
180+
return_value=_addrinfo('93.184.216.34')):
181+
result = fetch_webpage_content('https://example.com/old')
182+
183+
self.assertEqual(result, 'Moved content')
184+
self.assertEqual(mock_requests_get.call_count, 2)
185+
self.assertEqual(mock_requests_get.call_args_list[1][0][0],
186+
'https://example.com/moved')
187+
188+
@patch('optillm.plugins.readurls_plugin.requests.get')
189+
def test_fetch_allow_internal_opt_out(self, mock_requests_get):
190+
"""Operators can explicitly allow internal fetches."""
191+
from optillm.plugins.readurls_plugin import fetch_webpage_content
192+
193+
server_config['readurls_allow_internal'] = True
194+
195+
mock_response = MagicMock()
196+
mock_response.content = b'<html><body><p>Internal docs</p></body></html>'
197+
mock_response.raise_for_status = MagicMock()
198+
mock_requests_get.return_value = mock_response
199+
200+
result = fetch_webpage_content('http://127.0.0.1:8080/internal-docs')
201+
202+
self.assertEqual(result, 'Internal docs')
203+
mock_requests_get.assert_called_once()
204+
205+
206+
class TestReadurlsRun(unittest.TestCase):
207+
"""run()-level tests with mixed public and internal URLs."""
208+
209+
def setUp(self):
210+
self.original_config = server_config.copy()
211+
212+
def tearDown(self):
213+
server_config.clear()
214+
server_config.update(self.original_config)
215+
216+
@patch('optillm.plugins.readurls_plugin.requests.get')
217+
def test_run_blocks_internal_and_keeps_public(self, mock_requests_get):
218+
from optillm.plugins.readurls_plugin import run
219+
220+
mock_response = MagicMock()
221+
mock_response.content = b'<html><body><p>Public page text</p></body></html>'
222+
mock_response.raise_for_status = MagicMock()
223+
mock_requests_get.return_value = mock_response
224+
225+
query = f"Summarize {METADATA_URL} and https://example.com/page"
226+
with patch('optillm.plugins.readurls_plugin.socket.getaddrinfo',
227+
side_effect=_getaddrinfo_by_host({'example.com': '93.184.216.34'})):
228+
modified_query, _ = run("", query)
229+
230+
# Only the public URL reached the network layer
231+
self.assertEqual(mock_requests_get.call_count, 1)
232+
self.assertIn('blocked non-public URL', modified_query)
233+
self.assertIn('Public page text', modified_query)
234+
235+
236+
if __name__ == '__main__':
237+
unittest.main()

0 commit comments

Comments
 (0)