From bf55e0cba4e55deee093062110afc887afc940ec Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 1 Apr 2026 12:22:28 +0000 Subject: [PATCH] fix(studio): resolve SSL handshake failure in web_search tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSRF protection in _fetch_page_text() rewrote URLs to use the resolved IP directly, which broke TLS SNI — servers rejected the handshake because SNI sent the IP instead of the hostname. Replace the URL-rewriting approach with custom HTTPSHandler/HTTPHandler classes that pin the resolved IP at the socket level while preserving the original hostname for correct SNI negotiation. Also add certifi as a dependency for reliable CA certificate resolution. --- studio/backend/core/inference/tools.py | 93 +++++++++++++++++++++++--- studio/backend/requirements/studio.txt | 1 + 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 65302fe2f3..44028b32f1 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -216,9 +216,11 @@ def _fetch_page_text( return reason try: + import http.client + import ssl import urllib.request from urllib.error import HTTPError as _HTTPError - from urllib.parse import urljoin, urlunparse + from urllib.parse import urljoin # Disable auto-redirect so we can validate each hop for SSRF. # urllib raises HTTPError for 3xx when the handler returns None, @@ -227,23 +229,93 @@ def _fetch_page_text( def redirect_request(self, req, fp, code, msg, headers, newurl): return None - opener = urllib.request.build_opener(_NoRedirect) + class _PinnedHTTPSConnection(http.client.HTTPSConnection): + """HTTPS connection that connects to a pinned IP while + preserving the original hostname for SNI and certificate + verification. This prevents DNS rebinding between the + SSRF validation step and the actual fetch.""" + + _pinned_ip: str | None = None + + def connect(self): + import socket + + ip = self._pinned_ip or self.host + self.sock = socket.create_connection( + (ip, self.port), self.timeout, + ) + if self._context: + ctx = self._context + else: + try: + import certifi + ctx = ssl.create_default_context(cafile = certifi.where()) + except ImportError: + ctx = ssl.create_default_context() + self.sock = ctx.wrap_socket( + self.sock, server_hostname = self.host, + ) + + class _PinnedHTTPSHandler(urllib.request.HTTPSHandler): + """HTTPSHandler that routes connections through a pinned IP.""" + + def __init__(self, pinned_ip: str): + super().__init__() + self._pinned_ip = pinned_ip + + def https_open(self, req): + return self.do_open(self._make_connection, req) + + def _make_connection(self, host, **kwargs): + conn = _PinnedHTTPSConnection(host, **kwargs) + conn._pinned_ip = self._pinned_ip + return conn + + class _PinnedHTTPConnection(http.client.HTTPConnection): + """HTTP connection that connects to a pinned IP while + preserving the original hostname in the Host header.""" + + _pinned_ip: str | None = None + + def connect(self): + import socket + + ip = self._pinned_ip or self.host + self.sock = socket.create_connection( + (ip, self.port), self.timeout, + ) + + class _PinnedHTTPHandler(urllib.request.HTTPHandler): + """HTTPHandler that routes connections through a pinned IP.""" + + def __init__(self, pinned_ip: str): + super().__init__() + self._pinned_ip = pinned_ip + + def http_open(self, req): + return self.do_open(self._make_connection, req) + + def _make_connection(self, host, **kwargs): + conn = _PinnedHTTPConnection(host, **kwargs) + conn._pinned_ip = self._pinned_ip + return conn + max_bytes = max_chars * 4 + 1 current_url = url - current_host = parsed.hostname for _hop in range(5): - # Pin to the validated IP to prevent DNS rebinding. - # Rewrite the URL to use the IP and set the Host header. - cp = urlparse(current_url) - ip_netloc = f"{pinned_ip}:{cp.port}" if cp.port else pinned_ip - pinned_url = urlunparse(cp._replace(netloc = ip_netloc)) + # Build opener with IP-pinning handler to prevent DNS rebinding. + # The original hostname is preserved in the URL for correct SNI. + if urlparse(current_url).scheme == "https": + pin_handler = _PinnedHTTPSHandler(pinned_ip) + else: + pin_handler = _PinnedHTTPHandler(pinned_ip) + opener = urllib.request.build_opener(_NoRedirect, pin_handler) req = urllib.request.Request( - pinned_url, + current_url, headers = { "User-Agent": "UnslothStudio/1.0", - "Host": current_host, }, ) try: @@ -267,7 +339,6 @@ def _fetch_page_text( ) if not ok2: return reason2 - current_host = rp.hostname continue # Success -- read capped body raw_bytes = resp.read(max_bytes) diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt index 186ba82fe0..1a060c305f 100644 --- a/studio/backend/requirements/studio.txt +++ b/studio/backend/requirements/studio.txt @@ -15,3 +15,4 @@ huggingface-hub==0.36.2 structlog>=24.1.0 diceware ddgs +certifi