Merge #5407: pin OpenAI TTS proxy connect target vs DNS-rebind TOCTOU (#5291) [rodboev]

This commit is contained in:
nesquena-hermes
2026-07-02 17:45:04 +00:00
2 changed files with 367 additions and 33 deletions
+105 -33
View File
@@ -26,12 +26,14 @@ import sys
import threading
import time
import uuid
import http.client
import socket as _socket
from collections import defaultdict
from pathlib import Path
from contextlib import closing
from urllib.parse import parse_qs, quote, urljoin, urlsplit
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
from api.agent_sessions import (
MESSAGING_SOURCES,
_looks_like_default_cli_title,
@@ -16299,6 +16301,24 @@ _TTS_PROXY_MAX_BYTES = 16 * 1024 * 1024
_TTS_LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost"}
def _tts_addr_is_blocked(ip_str: str) -> bool:
"""Return True when IP is in a private or otherwise non-routable class."""
import ipaddress
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
)
def _tts_host_is_blocked_target(hostname: str) -> bool:
"""True if the hostname resolves to (or literally is) a private / loopback /
link-local / reserved / multicast address the SSRF-risk targets that an
@@ -16312,24 +16332,10 @@ def _tts_host_is_blocked_target(hostname: str) -> bool:
if not host:
return True
def _addr_blocked(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
)
# Literal IP host?
try:
ipaddress.ip_address(host)
return _addr_blocked(host)
return _tts_addr_is_blocked(host)
except ValueError:
pass
@@ -16346,11 +16352,42 @@ def _tts_host_is_blocked_target(hostname: str) -> bool:
return False
for info in infos:
sockaddr = info[4]
if sockaddr and _addr_blocked(str(sockaddr[0])):
if sockaddr and _tts_addr_is_blocked(str(sockaddr[0])):
return True
return False
def _tts_resolve_pinned_addresses(hostname: str, port: int | None) -> list[str]:
"""Resolve once, validate the RRset, and preserve candidate dial order."""
import socket
host = (hostname or "").strip().lower()
if not host:
raise ValueError("invalid OpenAI TTS base_url host")
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except Exception as exc:
raise ValueError("could not resolve OpenAI TTS base_url host") from exc
pinned_hosts = []
for info in infos:
sockaddr = info[4]
if not sockaddr:
continue
pinned_host = str(sockaddr[0])
if _tts_addr_is_blocked(pinned_host):
raise ValueError("resolved OpenAI TTS target is not allowed")
pinned_hosts.append(pinned_host)
if not pinned_hosts:
raise ValueError("could not resolve OpenAI TTS base_url host")
return pinned_hosts
def _tts_resolve_pinned_address(hostname: str) -> str:
"""Return the first vetted literal address for direct helper callers."""
return _tts_resolve_pinned_addresses(hostname, None)[0]
def _normalized_openai_tts_base_url(base_url: str) -> str:
from urllib.parse import urlsplit, urlunsplit
@@ -16410,6 +16447,52 @@ def _buffer_tts_audio_response(resp, *, max_bytes: int | None = None) -> bytes:
raise ValueError("upstream audio exceeded byte limit")
return bytes(audio_data)
class _NoRedirectTtsHandler(HTTPRedirectHandler):
"""Refuse to follow redirects on the TTS call.
A redirect is never a legitimate response to POST /audio/speech and can
carry the Authorization bearer to a target that bypasses the base_url check.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ValueError("OpenAI TTS upstream attempted a redirect")
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
"""Connect to a pinned IP while keeping Host and TLS SNI on the hostname."""
def connect(self):
sys.audit("http.client.connect", self, self.host, self.port)
last_error = None
for pinned_host in _tts_resolve_pinned_addresses(self.host, self.port):
try:
self.sock = _socket.create_connection(
(pinned_host, self.port), self.timeout, self.source_address
)
break
except OSError as exc:
last_error = exc
else:
if last_error is not None:
raise last_error
raise OSError("could not connect to any pinned OpenAI TTS target")
try:
self.sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
except OSError as exc:
if exc.errno != errno.ENOPROTOOPT:
raise
if self._tunnel_host:
self._tunnel()
server_hostname = self._tunnel_host or self.host
self.sock = self._context.wrap_socket(self.sock, server_hostname=server_hostname)
class _PinnedHTTPSHandler(HTTPSHandler):
def https_open(self, req):
return self.do_open(_PinnedHTTPSConnection, req, context=self._context)
def _tts_open(req, *, timeout=30, opener_factory=None):
"""Thin network seam for the TTS upstream fetch so tests can intercept it.
@@ -16661,29 +16744,18 @@ def _handle_tts(handler, parsed):
"voice": oai_voice,
}).encode("utf-8")
from urllib.request import Request, build_opener, HTTPRedirectHandler, urlopen as _urlopen
class _NoRedirectTtsHandler(HTTPRedirectHandler):
"""Refuse to follow redirects on the TTS call. A redirect is never a
legitimate response to a POST /audio/speech, and following one would
(a) carry the Authorization bearer to the redirect target and
(b) let a public host bounce the request to a private/link-local
SSRF target after the base-url validation already passed."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ValueError("OpenAI TTS upstream attempted a redirect")
from urllib.request import Request, build_opener, urlopen as _urlopen
req = Request(url, data=req_body, headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "audio/mpeg",
})
# Use a no-redirect opener so an upstream redirect can't carry the bearer
# to (or SSRF-bounce into) a different/private target. _tts_open is a thin
# module seam so tests can still intercept the network call.
# Use a pinned HTTPS opener so the resolved address is the one that gets
# dialed. Keep the no-redirect handler in the same chain to block
# bearer leaks and SSRF bounce redirects after hostname validation.
try:
with _tts_open(req, timeout=30, opener_factory=lambda: build_opener(_NoRedirectTtsHandler())) as resp:
with _tts_open(req, timeout=30, opener_factory=lambda: build_opener(ProxyHandler({}), _NoRedirectTtsHandler(), _PinnedHTTPSHandler())) as resp:
audio_data = _buffer_tts_audio_response(resp)
except ValueError:
logger.warning("OpenAI TTS rejected an invalid upstream response", exc_info=True)