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

132 lines
5.6 KiB
Python

import http.server
import socket
import threading
from collections.abc import Iterator
from unittest import mock
import pytest
import requests
from urllib3.util.retry import Retry
from mlflow.webhooks.delivery import _create_webhook_session
from mlflow.webhooks.ssrf import SSRFProtectedHTTPAdapter, SSRFProtectionError
@pytest.fixture
def loopback_server() -> Iterator[int]:
"""A real HTTP server on loopback, standing in for an internal/metadata endpoint."""
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"SECRET")
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, name="ssrf-test-loopback", daemon=True)
thread.start()
try:
yield port
finally:
server.shutdown()
def _rebind_getaddrinfo(rebind_host: str, target_host: str, target_port: int):
"""getaddrinfo that resolves rebind_host to (target_host, target_port)."""
orig = socket.getaddrinfo
def resolver(host, port, *args, **kwargs):
if host == rebind_host:
return orig(target_host, target_port, *args, **kwargs)
return orig(host, port, *args, **kwargs)
return resolver
def test_webhook_session_uses_ssrf_protected_adapter():
session = _create_webhook_session()
for scheme in ("http://", "https://"):
adapter = session.get_adapter(f"{scheme}example.com")
assert type(adapter).__name__ == "SSRFProtectedHTTPAdapter"
def test_webhook_session_ignores_env_proxy(loopback_server: int, monkeypatch: pytest.MonkeyPatch):
# With trust_env left on, an HTTP_PROXY env var would route the request
# through the proxy, making the validated peer the proxy (not the webhook
# destination) and bypassing the SSRF check entirely. The session disables
# env proxy handling, so the loopback "proxy" must never be reached.
session = _create_webhook_session()
assert session.trust_env is False
monkeypatch.setenv("HTTP_PROXY", f"http://127.0.0.1:{loopback_server}")
resolver = _rebind_getaddrinfo("dest.example.com", "127.0.0.1", loopback_server)
with mock.patch.object(socket, "getaddrinfo", side_effect=resolver):
# The real destination still resolves to loopback here, so the peer
# check fires on the destination — proving the proxy was bypassed and
# the destination IP is what gets validated.
with pytest.raises(SSRFProtectionError, match="not a public IP"):
session.post("http://dest.example.com/hook", timeout=10)
def test_dns_rebinding_to_loopback_is_blocked(loopback_server: int):
session = _create_webhook_session()
resolver = _rebind_getaddrinfo("evil.example.com", "127.0.0.1", loopback_server)
with mock.patch.object(socket, "getaddrinfo", side_effect=resolver):
with pytest.raises(SSRFProtectionError, match="not a public IP"):
session.get(f"http://evil.example.com:{loopback_server}/latest/meta-data/", timeout=10)
def test_private_ip_connection_is_blocked_by_default(loopback_server: int):
session = _create_webhook_session()
with pytest.raises(SSRFProtectionError, match="not a public IP"):
session.get(f"http://127.0.0.1:{loopback_server}/", timeout=10)
def test_private_ip_allowed_when_env_var_set(loopback_server: int, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("MLFLOW_WEBHOOK_ALLOW_PRIVATE_IPS", "true")
session = _create_webhook_session()
response = session.get(f"http://127.0.0.1:{loopback_server}/", timeout=10)
assert response.status_code == 200
def test_ssrf_error_is_not_retried(loopback_server: int):
# SSRFProtectionError is not a urllib3 exception, so it propagates on the
# first connection attempt rather than being retried. Use POST to exercise
# the production retry config (allowed_methods=["POST"]).
session = _create_webhook_session()
with mock.patch("mlflow.webhooks.ssrf.ipaddress.ip_address") as mock_ip:
mock_ip.return_value.is_global = False
with pytest.raises(SSRFProtectionError, match="not a public IP"):
session.post(f"http://127.0.0.1:{loopback_server}/", timeout=10)
# One check per connection attempt; no retry loop.
mock_ip.assert_called_once()
def test_explicit_proxy_to_private_ip_is_blocked(loopback_server: int):
# An explicitly configured proxy routes through proxy_manager_for, not the
# direct pool manager. The adapter's proxy manager must also use protected
# pools so the proxy endpoint's peer IP is validated.
session = requests.Session()
adapter = SSRFProtectedHTTPAdapter(max_retries=Retry(total=0))
session.mount("http://", adapter)
session.mount("https://", adapter)
session.proxies = {"http": f"http://127.0.0.1:{loopback_server}"}
assert type(adapter.proxy_manager_for(f"http://127.0.0.1:{loopback_server}")).__name__ == (
"_SSRFProtectedProxyManager"
)
with pytest.raises(SSRFProtectionError, match="not a public IP"):
session.post("http://example.com/hook", timeout=10)
def test_getpeername_oserror_fails_closed(loopback_server: int):
# If getpeername() raises OSError (e.g. ENOTCONN), the check must fail closed
# with SSRFProtectionError rather than leak an uncaught OSError.
session = _create_webhook_session()
with mock.patch.object(socket.socket, "getpeername", side_effect=OSError("ENOTCONN")):
with pytest.raises(SSRFProtectionError, match="peer address"):
session.post(f"http://127.0.0.1:{loopback_server}/", timeout=10)