Skip to content

Commit 6ab18ae

Browse files
fix: add client-side SSRF redirect protection to httpx client factory
The Streamable HTTP client factory unconditionally sets follow_redirects=True and never validates redirect targets. A compromised or attacker-influenced MCP server can return a 307/308 that bounces JSON-RPC traffic onto loopback/link-local/private hosts (local agents, metadata endpoints), and the client accepts the internal reply as the server's own — the client-side mirror of the server-side DNS-rebinding protection already present in transport_security.py. Introduce RedirectPolicy (NONE/SAME_HOST/SAFE/ALL, default SAFE) and a request event hook that records the caller-chosen origin and blocks redirect hops onto non-global addresses. Legitimate public redirects are still followed; ALL preserves legacy behavior explicitly. streamable_http_client gains a redirect_policy passthrough and warns when a caller-supplied httpx2.AsyncClient skips this protection.
1 parent d2290ca commit 6ab18ae

3 files changed

Lines changed: 280 additions & 7 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections.abc import AsyncGenerator, Awaitable, Callable
88
from contextlib import asynccontextmanager
99
from dataclasses import dataclass
10+
from typing import Any
1011

1112
import anyio
1213
import httpx2
@@ -33,7 +34,7 @@
3334
from mcp.client._transport import TransportStreams
3435
from mcp.shared._compat import resync_tracer
3536
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
36-
from mcp.shared._httpx_utils import create_mcp_http_client
37+
from mcp.shared._httpx_utils import RedirectPolicy, create_mcp_http_client
3738
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
3839
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
3940
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@@ -642,6 +643,7 @@ async def streamable_http_client(
642643
*,
643644
http_client: httpx2.AsyncClient | None = None,
644645
terminate_on_close: bool = True,
646+
redirect_policy: RedirectPolicy | None = None,
645647
) -> AsyncGenerator[TransportStreams, None]:
646648
"""Client transport for StreamableHTTP.
647649
@@ -651,6 +653,10 @@ async def streamable_http_client(
651653
client with recommended MCP timeouts will be created. To configure headers,
652654
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
653655
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
656+
redirect_policy: How to handle server 3xx redirects when the built-in
657+
client is used (see ``RedirectPolicy``). Ignored when ``http_client``
658+
is provided — a caller-supplied client manages its own redirects and
659+
is not protected from being bounced onto internal/loopback hosts.
654660
655661
Yields:
656662
Tuple containing:
@@ -666,7 +672,12 @@ async def streamable_http_client(
666672

667673
if client is None:
668674
# Create default client with recommended MCP timeouts
669-
client = create_mcp_http_client()
675+
kwargs: dict[str, Any] = {}
676+
if redirect_policy is not None:
677+
kwargs["redirect_policy"] = redirect_policy
678+
client = create_mcp_http_client(**kwargs)
679+
else:
680+
logger.debug("Using user-provided HTTP client; MCP redirect/SSRF protection is not applied")
670681

671682
transport = StreamableHTTPTransport(url)
672683

src/mcp/shared/_httpx_utils.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,145 @@
11
"""Utilities for creating standardized httpx2 AsyncClient instances."""
22

3+
import ipaddress
4+
import logging
5+
from enum import Enum
36
from typing import Any, Protocol
47

58
import httpx2
69

7-
__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"]
10+
logger = logging.getLogger(__name__)
11+
12+
__all__ = [
13+
"create_mcp_http_client",
14+
"MCP_DEFAULT_TIMEOUT",
15+
"MCP_DEFAULT_SSE_READ_TIMEOUT",
16+
"RedirectPolicy",
17+
]
818

919
# Default MCP timeout configuration
1020
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
1121
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
1222

23+
# Well-known names that resolve to loopback (RFC 6761 reserves *.localhost).
24+
_LOOPBACK_HOSTNAMES = ("localhost", ".localhost")
25+
26+
27+
class RedirectPolicy(Enum):
28+
"""Controls how the MCP HTTP client handles server 3xx redirects.
29+
30+
Streamable HTTP is JSON-RPC over HTTP; an attacker-influenced server can
31+
return a ``307``/``308`` that bounces the client's JSON-RPC traffic onto an
32+
internal or loopback endpoint (a local agent, a metadata service, a
33+
registry), and the client will accept that endpoint's reply as the MCP
34+
server's own. This is the client-side mirror of the server-side DNS
35+
rebinding protection in ``mcp.server.transport_security``.
36+
37+
Attributes:
38+
NONE: Never follow redirects (``follow_redirects=False``).
39+
SAME_HOST: Only follow redirects that stay on the same scheme and host.
40+
SAFE: Follow any redirect whose target is not a loopback, link-local,
41+
private, multicast or otherwise non-global address. This is the
42+
default: legitimate public redirects (e.g. a migrated endpoint)
43+
still work, while bounce-into-internal attacks are blocked.
44+
ALL: Follow any redirect (the historical behavior). Primarily useful as
45+
an explicit opt-out.
46+
"""
47+
48+
NONE = "none"
49+
SAME_HOST = "same_host"
50+
SAFE = "safe"
51+
ALL = "all"
52+
53+
54+
def _is_internal_or_non_global(host: str) -> bool:
55+
"""Return True when a literal host is loopback/link-local/private/etc.
56+
57+
Hostnames (other than the reserved ``localhost``/``*.localhost`` names) are
58+
treated as external, since a deterministic check would require resolving
59+
them via DNS from the event hook.
60+
"""
61+
if not host:
62+
return True
63+
64+
lower = host.lower().rstrip(".")
65+
if lower == "localhost" or lower.endswith(_LOOPBACK_HOSTNAMES):
66+
return True
67+
68+
try:
69+
ip = ipaddress.ip_address(host)
70+
except ValueError:
71+
return False
72+
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified
73+
74+
75+
def _make_redirect_guard(policy: RedirectPolicy):
76+
"""Build an httpx ``request`` event hook enforcing ``policy``.
77+
78+
The hook is invoked for the initial request and for every redirect hop.
79+
The first request's origin authorizes whatever host the caller explicitly
80+
chose (a user may legitimately target their own loopback server); only
81+
subsequent hops are validated.
82+
"""
83+
if policy is RedirectPolicy.NONE or policy is RedirectPolicy.ALL:
84+
return None
85+
86+
origin: tuple | None = None
87+
88+
async def guard(request) -> None:
89+
nonlocal origin
90+
target = (request.url.scheme, request.url.host, request.url.port)
91+
if origin is None:
92+
origin = target
93+
return
94+
if target == origin:
95+
return
96+
if policy is RedirectPolicy.SAME_HOST:
97+
if target[:2] != origin[:2]:
98+
raise httpx2.ConnectError(
99+
f"Blocked redirect to a different host '{request.url}' (redirect policy: {policy.value})"
100+
)
101+
elif policy is RedirectPolicy.SAFE and _is_internal_or_non_global(target[1]):
102+
raise httpx2.ConnectError(
103+
f"Blocked redirect to internal/private host '{request.url}' "
104+
f"(redirect policy: {policy.value}); refusing to send JSON-RPC "
105+
f"traffic to a non-global address"
106+
)
107+
108+
return guard
109+
13110

14111
class McpHttpClientFactory(Protocol): # pragma: no branch
15112
def __call__( # pragma: no branch
16113
self,
17114
headers: dict[str, str] | None = None,
18115
timeout: httpx2.Timeout | None = None,
19116
auth: httpx2.Auth | None = None,
117+
redirect_policy: RedirectPolicy = RedirectPolicy.SAFE,
20118
) -> httpx2.AsyncClient: ...
21119

22120

23121
def create_mcp_http_client(
24122
headers: dict[str, str] | None = None,
25123
timeout: httpx2.Timeout | None = None,
26124
auth: httpx2.Auth | None = None,
125+
redirect_policy: RedirectPolicy = RedirectPolicy.SAFE,
27126
) -> httpx2.AsyncClient:
28127
"""Create a standardized httpx2 AsyncClient with MCP defaults.
29128
30-
Always enables follow_redirects and applies an SSE-friendly default timeout.
129+
Builds a client that follows redirects by default, applies an SSE-friendly
130+
default timeout, and protects against server-driven SSRF: redirect targets
131+
are validated so the client never bounces JSON-RPC traffic onto internal or
132+
loopback hosts (see ``RedirectPolicy``).
31133
32134
Args:
33135
headers: Optional headers to include with all requests.
34136
timeout: Request timeout as httpx2.Timeout object. Defaults to 30s for
35137
connect/write/pool and 300s for read (for long-lived SSE streams).
36138
auth: Optional authentication handler.
139+
redirect_policy: How to handle server 3xx redirects. Defaults to
140+
``RedirectPolicy.SAFE``, which blocks redirects into loopback,
141+
link-local, private and other non-global addresses while still
142+
following legitimate public redirects.
37143
38144
Returns:
39145
Configured httpx2.AsyncClient instance with MCP defaults.
@@ -76,7 +182,15 @@ def create_mcp_http_client(
76182
```
77183
"""
78184
# Set MCP defaults
79-
kwargs: dict[str, Any] = {"follow_redirects": True}
185+
kwargs: dict[str, Any] = {}
186+
187+
if redirect_policy is RedirectPolicy.NONE:
188+
kwargs["follow_redirects"] = False
189+
else:
190+
kwargs["follow_redirects"] = True
191+
guard = _make_redirect_guard(redirect_policy)
192+
if guard is not None:
193+
kwargs["event_hooks"] = {"request": [guard]}
80194

81195
# Handle timeout
82196
if timeout is None:

tests/shared/test_httpx_utils.py

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
"""Tests for httpx2 utility functions."""
1+
"""Tests for httpx2 client factory and its SSRF redirect protection."""
2+
3+
import asyncio
4+
import threading
5+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26

37
import httpx2
8+
import pytest
49

5-
from mcp.shared._httpx_utils import create_mcp_http_client
10+
from mcp.shared._httpx_utils import (
11+
RedirectPolicy,
12+
_is_internal_or_non_global,
13+
create_mcp_http_client,
14+
)
615

716

817
def test_default_settings():
@@ -22,3 +31,142 @@ def test_custom_parameters():
2231

2332
assert client.headers["Authorization"] == "Bearer token"
2433
assert client.timeout.connect == 60.0
34+
35+
36+
def test_redirect_policy_none_disables_follow():
37+
"""NONE must set follow_redirects=False and install no guard."""
38+
client = create_mcp_http_client(redirect_policy=RedirectPolicy.NONE)
39+
assert client.follow_redirects is False
40+
assert not client._event_hooks["request"]
41+
42+
43+
@pytest.mark.parametrize(
44+
"host,expected",
45+
[
46+
("127.0.0.1", True),
47+
("localhost", True),
48+
("sub.localhost", True),
49+
("10.0.0.5", True),
50+
("172.16.1.1", True),
51+
("172.31.255.255", True),
52+
("192.168.1.10", True),
53+
("169.254.169.254", True), # cloud metadata endpoint
54+
("::1", True),
55+
("fc00::1", True),
56+
("fe80::1", True),
57+
("0.0.0.0", True),
58+
# Public / non-literal hosts must be treated as external
59+
("93.184.216.34", False),
60+
("example.com", False),
61+
("1.2.3.4", False),
62+
],
63+
)
64+
def test_is_internal_or_non_global(host, expected):
65+
assert _is_internal_or_non_global(host) is expected
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# Redirect guard integration: a live local server that bounces HTTP to a target.
70+
# ---------------------------------------------------------------------------
71+
72+
73+
class _RedirectServerHandler(BaseHTTPRequestHandler):
74+
protocol_version = "HTTP/1.1"
75+
redirect_status = 307
76+
target = None # set per-server
77+
78+
def log_message(self, *args): # keep test output clean
79+
pass
80+
81+
def do_GET(self):
82+
if not self.target or self.path != "/":
83+
body = b"ok"
84+
self.send_response(200)
85+
self.send_header("Content-Length", str(len(body)))
86+
self.end_headers()
87+
self.wfile.write(body)
88+
return
89+
self.send_response(self.redirect_status)
90+
self.send_header("Location", self.target)
91+
self.send_header("Content-Length", "0")
92+
self.end_headers()
93+
94+
95+
class _TargetHandler(BaseHTTPRequestHandler):
96+
protocol_version = "HTTP/1.1"
97+
98+
def log_message(self, *args):
99+
pass
100+
101+
def do_GET(self):
102+
body = b"internal-reply"
103+
self.send_response(200)
104+
self.send_header("Content-Length", str(len(body)))
105+
self.end_headers()
106+
self.wfile.write(body)
107+
108+
109+
class _Server:
110+
def __init__(self, handler):
111+
self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
112+
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
113+
self._thread.start()
114+
115+
@property
116+
def port(self):
117+
return self._httpd.server_address[1]
118+
119+
def close(self):
120+
self._httpd.shutdown()
121+
self._httpd.server_close()
122+
123+
124+
@pytest.fixture(scope="module")
125+
def servers():
126+
target = _Server(_TargetHandler)
127+
redirector = _Server(_RedirectServerHandler)
128+
yield redirector, target
129+
redirector.close()
130+
target.close()
131+
132+
133+
def _get(client: httpx2.AsyncClient, url: str) -> httpx2.Response:
134+
return asyncio.run(client.get(url))
135+
136+
137+
def test_safe_policy_blocks_redirect_to_internal(servers):
138+
"""Default SAFE policy must refuse to follow a redirect into loopback."""
139+
redirector, target = servers
140+
_RedirectServerHandler.target = f"http://127.0.0.1:{target.port}/injected"
141+
client = create_mcp_http_client() # default = SAFE
142+
with pytest.raises(httpx2.ConnectError, match="internal/private host"):
143+
_get(client, f"http://127.0.0.1:{redirector.port}/")
144+
145+
146+
def test_all_policy_follows_redirect_to_internal(servers):
147+
"""Explicit ALL keeps legacy behavior: redirect into loopback is followed."""
148+
redirector, target = servers
149+
_RedirectServerHandler.target = f"http://127.0.0.1:{target.port}/injected"
150+
client = create_mcp_http_client(redirect_policy=RedirectPolicy.ALL)
151+
resp = _get(client, f"http://127.0.0.1:{redirector.port}/")
152+
assert resp.status_code == 200
153+
assert resp.text == "internal-reply"
154+
155+
156+
def test_same_host_policy_still_follows_same_host_redirect(servers):
157+
"""SAME_HOST must not block the common same-host bounce."""
158+
redirector, _ = servers
159+
_RedirectServerHandler.target = f"http://127.0.0.1:{redirector.port}/noop"
160+
client = create_mcp_http_client(redirect_policy=RedirectPolicy.SAME_HOST)
161+
resp = _get(client, f"http://127.0.0.1:{redirector.port}/")
162+
assert resp.status_code == 200
163+
assert resp.text == "ok"
164+
165+
166+
def test_none_policy_does_not_follow_redirect(servers):
167+
"""NONE must return the 3xx without following, so no rebinding occurs."""
168+
redirector, _ = servers
169+
_RedirectServerHandler.target = "http://127.0.0.1:9999/nope"
170+
client = create_mcp_http_client(redirect_policy=RedirectPolicy.NONE)
171+
resp = _get(client, f"http://127.0.0.1:{redirector.port}/")
172+
assert resp.status_code == 307

0 commit comments

Comments
 (0)