Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Roland Tannous
2fc61ebab3
Merge branch 'main' into fix/web-search-ssl-sni 2026-04-01 16:48:40 +04:00
Roland Tannous
ed836f143f fix: address PR review — use _create_connection override, reuse SSL context
- 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
2026-04-01 12:46:18 +00:00
pre-commit-ci[bot]
3182fd953c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-04-01 12:37:07 +00:00
Roland Tannous
bf55e0cba4 fix(studio): resolve SSL handshake failure in web_search tool calls
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.
2026-04-01 12:22:28 +00:00
2 changed files with 81 additions and 11 deletions

View file

@ -215,9 +215,17 @@ def _fetch_page_text(
return reason
try:
import http.client
import socket
import ssl
import urllib.request
from urllib.error import HTTPError as _HTTPError
from urllib.parse import urljoin, urlunparse
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,
@ -226,23 +234,85 @@ 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 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 _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, context = None):
super().__init__(context = context)
self._pinned_ip = pinned_ip
def https_open(self, req):
return self.do_open(self._make_connection, req, context = self._context)
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 pins to a pre-validated IP."""
_pinned_ip: str | None = None
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):
"""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
# 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):
# 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, context = ssl_ctx)
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:
@ -266,7 +336,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)

View file

@ -15,3 +15,4 @@ huggingface-hub==0.36.2
structlog>=24.1.0
diceware
ddgs
certifi