From bf55e0cba4e55deee093062110afc887afc940ec Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 1 Apr 2026 12:22:28 +0000 Subject: [PATCH 1/3] 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 From 3182fd953c0c977a9ebf059c108a78a8834b8216 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:37:05 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/tools.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 44028b32f1..b27464d068 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -242,18 +242,21 @@ def _fetch_page_text( ip = self._pinned_ip or self.host self.sock = socket.create_connection( - (ip, self.port), self.timeout, + (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, + self.sock, + server_hostname = self.host, ) class _PinnedHTTPSHandler(urllib.request.HTTPSHandler): @@ -282,7 +285,8 @@ def _fetch_page_text( ip = self._pinned_ip or self.host self.sock = socket.create_connection( - (ip, self.port), self.timeout, + (ip, self.port), + self.timeout, ) class _PinnedHTTPHandler(urllib.request.HTTPHandler): From ed836f143f5c8fa2b9648106b81e8be9dde173c2 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 1 Apr 2026 12:42:42 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?use=20=5Fcreate=5Fconnection=20override,=20reuse=20SSL=20contex?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Override _create_connection instead of connect() to preserve standard HTTPSConnection behavior (TCP_NODELAY, proxy CONNECT tunneling, etc.) - Create SSL context once before the redirect loop instead of per-hop - Set minimum TLS version to 1.2 (fixes CodeQL security scanner flag) - Consolidate imports (socket, certifi) at top of try block --- studio/backend/core/inference/tools.py | 68 ++++++++++++-------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index b27464d068..a467078bb7 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -217,11 +217,17 @@ def _fetch_page_text( try: import http.client + import socket import ssl import urllib.request from urllib.error import HTTPError as _HTTPError from urllib.parse import urljoin + try: + import certifi + except ImportError: + certifi = None + # Disable auto-redirect so we can validate each hop for SSRF. # urllib raises HTTPError for 3xx when the handler returns None, # so we catch that and extract the Location header manually. @@ -230,44 +236,29 @@ def _fetch_page_text( return None 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.""" + """HTTPS connection that pins to a pre-validated IP while + preserving the original hostname for SNI and cert verification. + Overrides ``_create_connection`` so that standard ``connect()`` + logic (TLS, proxy CONNECT tunneling, TCP_NODELAY) is preserved.""" _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, + def _create_connection(self, address, timeout, source_address): + return socket.create_connection( + (self._pinned_ip or address[0], address[1]), + timeout, + source_address, ) class _PinnedHTTPSHandler(urllib.request.HTTPSHandler): """HTTPSHandler that routes connections through a pinned IP.""" - def __init__(self, pinned_ip: str): - super().__init__() + def __init__(self, pinned_ip: str, context = None): + super().__init__(context = context) self._pinned_ip = pinned_ip def https_open(self, req): - return self.do_open(self._make_connection, req) + return self.do_open(self._make_connection, req, context = self._context) def _make_connection(self, host, **kwargs): conn = _PinnedHTTPSConnection(host, **kwargs) @@ -275,18 +266,15 @@ def _fetch_page_text( return conn class _PinnedHTTPConnection(http.client.HTTPConnection): - """HTTP connection that connects to a pinned IP while - preserving the original hostname in the Host header.""" + """HTTP connection that pins to a pre-validated IP.""" _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, + def _create_connection(self, address, timeout, source_address): + return socket.create_connection( + (self._pinned_ip or address[0], address[1]), + timeout, + source_address, ) class _PinnedHTTPHandler(urllib.request.HTTPHandler): @@ -307,11 +295,17 @@ def _fetch_page_text( max_bytes = max_chars * 4 + 1 current_url = url + # Create SSL context once and reuse across redirect hops. + ssl_ctx = ssl.create_default_context( + cafile = certifi.where() if certifi else None, + ) + ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + for _hop in range(5): # 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) + pin_handler = _PinnedHTTPSHandler(pinned_ip, context = ssl_ctx) else: pin_handler = _PinnedHTTPHandler(pinned_ip) opener = urllib.request.build_opener(_NoRedirect, pin_handler)