Files
2026-07-13 13:22:34 +08:00

145 lines
5.1 KiB
Python

"""Connection-time SSRF protection for outbound webhook delivery.
``_validate_webhook_url`` resolves the webhook hostname and checks that every
resolved IP is public, but it discards the resolved IP and the subsequent
``requests.post`` re-resolves the hostname independently. That TOCTOU gap lets a
DNS-rebinding attacker return a public IP during validation and a private or
link-local IP (e.g. ``169.254.169.254``) at request time, reaching cloud
metadata or internal services.
The pieces below close the gap by validating the peer IP of the *actual
connected socket* immediately after ``connect()`` returns, before any TLS
handshake or HTTP data is exchanged. There is no second DNS resolution between
the check and the request, so rebinding has nothing to exploit. Because the TLS
handshake still runs against the original hostname, certificate validation is
unaffected (unlike pinning the URL to a resolved IP).
Everything here is scoped to the webhook delivery session via
``SSRFProtectedHTTPAdapter`` so it never alters urllib3 behavior process-wide.
"""
import ipaddress
import socket
from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
from urllib3.connection import HTTPConnection, HTTPSConnection
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
from urllib3.poolmanager import PoolManager, ProxyManager
from mlflow.environment_variables import _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS
class SSRFProtectionError(Exception):
"""Raised when a webhook connection resolves to a non-public IP address.
Not a urllib3 exception type, so it is never retried and propagates
immediately: the delivery fails closed.
"""
def _assert_public_peer(sock: socket.socket) -> None:
if _MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS.get():
return
# getpeername() returns (host, port, ...) for both IPv4 and IPv6 sockets.
# It can raise OSError (e.g. ENOTCONN) on an unconnected socket; fail closed.
try:
peer_ip = sock.getpeername()[0]
except OSError as e:
sock.close()
raise SSRFProtectionError(
f"Could not determine webhook connection peer address: {e}"
) from e
try:
ip = ipaddress.ip_address(peer_ip)
except ValueError as e:
sock.close()
raise SSRFProtectionError(
f"Webhook connection resolved to an invalid IP address: {peer_ip!r}"
) from e
if not ip.is_global:
sock.close()
raise SSRFProtectionError(
f"Webhook connection blocked: {ip} is not a public IP address. "
"This may indicate a DNS rebinding attempt."
)
class _SSRFProtectedHTTPConnection(HTTPConnection):
def _new_conn(self) -> socket.socket:
sock = super()._new_conn()
_assert_public_peer(sock)
return sock
class _SSRFProtectedHTTPSConnection(HTTPSConnection):
def _new_conn(self) -> socket.socket:
# HTTPSConnection inherits _new_conn from HTTPConnection: it returns the
# raw TCP socket before the TLS handshake, so the IP check runs pre-TLS.
sock = super()._new_conn()
_assert_public_peer(sock)
return sock
class _SSRFProtectedHTTPConnectionPool(HTTPConnectionPool):
ConnectionCls = _SSRFProtectedHTTPConnection
class _SSRFProtectedHTTPSConnectionPool(HTTPSConnectionPool):
ConnectionCls = _SSRFProtectedHTTPSConnection
# Instance-local override installed on any (Pool|Proxy)Manager below, so only
# pools created by these managers get the SSRF-protected connection classes.
_SSRF_POOL_CLASSES = {
"http": _SSRFProtectedHTTPConnectionPool,
"https": _SSRFProtectedHTTPSConnectionPool,
}
class _SSRFProtectedPoolManager(PoolManager):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.pool_classes_by_scheme = _SSRF_POOL_CLASSES
class _SSRFProtectedProxyManager(ProxyManager):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.pool_classes_by_scheme = _SSRF_POOL_CLASSES
class SSRFProtectedHTTPAdapter(HTTPAdapter):
"""``HTTPAdapter`` that validates each connection's peer IP is public.
Both the direct pool manager and the proxy manager use SSRF-protected pools,
so the peer-IP check runs whether or not a proxy is configured.
"""
def init_poolmanager(
self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs
) -> None:
self._pool_connections = connections
self._pool_maxsize = maxsize
self._pool_block = block
self.poolmanager = _SSRFProtectedPoolManager(
num_pools=connections,
maxsize=maxsize,
block=block,
**pool_kwargs,
)
def proxy_manager_for(self, proxy, **proxy_kwargs):
if proxy not in self.proxy_manager:
proxy_headers = self.proxy_headers(proxy)
self.proxy_manager[proxy] = _SSRFProtectedProxyManager(
proxy,
proxy_headers=proxy_headers,
num_pools=self._pool_connections,
maxsize=self._pool_maxsize,
block=self._pool_block,
**proxy_kwargs,
)
return self.proxy_manager[proxy]