Skip to content

Commit 5423962

Browse files
committed
fix: add SSRF redirect protection to httpx client factory
Add a RedirectPolicy enum to create_mcp_http_client() that validates redirect targets via httpx event hooks. The default policy (BLOCK_SCHEME_DOWNGRADE) blocks HTTPS-to-HTTP redirect downgrades. ENFORCE_HTTPS restricts all redirects to HTTPS-only destinations. ALLOW_ALL preserves the previous unrestricted behavior. Github-Issue: #2106
1 parent 62575ed commit 5423962

2 files changed

Lines changed: 226 additions & 2 deletions

File tree

src/mcp/shared/_httpx_utils.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,115 @@
11
"""Utilities for creating standardized httpx AsyncClient instances."""
22

3+
import logging
4+
from enum import Enum
35
from typing import Any, Protocol
46

57
import httpx
68

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

918
# Default MCP timeout configuration
1019
MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds)
1120
MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds)
1221

1322

23+
class RedirectPolicy(Enum):
24+
"""Policy for validating HTTP redirects to protect against SSRF attacks.
25+
26+
Attributes:
27+
ALLOW_ALL: No restrictions on redirects (legacy behavior).
28+
BLOCK_SCHEME_DOWNGRADE: Block HTTPS-to-HTTP downgrades on redirect (default).
29+
ENFORCE_HTTPS: Only allow HTTPS redirect destinations.
30+
"""
31+
32+
ALLOW_ALL = "allow_all"
33+
BLOCK_SCHEME_DOWNGRADE = "block_scheme_downgrade"
34+
ENFORCE_HTTPS = "enforce_https"
35+
36+
37+
async def _check_redirect(response: httpx.Response, policy: RedirectPolicy) -> None:
38+
"""Validate redirect responses against the configured policy.
39+
40+
This is installed as an httpx response event hook. It inspects redirect
41+
responses (3xx with a ``next_request``) and raises
42+
:class:`httpx.HTTPStatusError` when the redirect violates *policy*.
43+
44+
Args:
45+
response: The httpx response to check.
46+
policy: The redirect policy to enforce.
47+
"""
48+
if not response.is_redirect or response.next_request is None:
49+
return
50+
51+
original_url = response.request.url
52+
redirect_url = response.next_request.url
53+
54+
if policy == RedirectPolicy.BLOCK_SCHEME_DOWNGRADE:
55+
if original_url.scheme == "https" and redirect_url.scheme == "http":
56+
logger.warning(
57+
"Blocked HTTPS-to-HTTP redirect from %s to %s",
58+
original_url,
59+
redirect_url,
60+
)
61+
raise httpx.HTTPStatusError(
62+
f"HTTPS-to-HTTP redirect blocked: {original_url} -> {redirect_url}",
63+
request=response.request,
64+
response=response,
65+
)
66+
elif policy == RedirectPolicy.ENFORCE_HTTPS:
67+
if redirect_url.scheme != "https":
68+
logger.warning(
69+
"Blocked non-HTTPS redirect from %s to %s",
70+
original_url,
71+
redirect_url,
72+
)
73+
raise httpx.HTTPStatusError(
74+
f"Non-HTTPS redirect blocked: {original_url} -> {redirect_url}",
75+
request=response.request,
76+
response=response,
77+
)
78+
79+
1480
class McpHttpClientFactory(Protocol): # pragma: no branch
1581
def __call__( # pragma: no branch
1682
self,
1783
headers: dict[str, str] | None = None,
1884
timeout: httpx.Timeout | None = None,
1985
auth: httpx.Auth | None = None,
86+
redirect_policy: RedirectPolicy = RedirectPolicy.BLOCK_SCHEME_DOWNGRADE,
2087
) -> httpx.AsyncClient: ...
2188

2289

2390
def create_mcp_http_client(
2491
headers: dict[str, str] | None = None,
2592
timeout: httpx.Timeout | None = None,
2693
auth: httpx.Auth | None = None,
94+
redirect_policy: RedirectPolicy = RedirectPolicy.BLOCK_SCHEME_DOWNGRADE,
2795
) -> httpx.AsyncClient:
2896
"""Create a standardized httpx AsyncClient with MCP defaults.
2997
3098
This function provides common defaults used throughout the MCP codebase:
3199
- follow_redirects=True (always enabled)
32100
- Default timeout of 30 seconds if not specified
101+
- SSRF redirect protection via *redirect_policy*
33102
34103
Args:
35104
headers: Optional headers to include with all requests.
36105
timeout: Request timeout as httpx.Timeout object.
37106
Defaults to 30 seconds if not specified.
38107
auth: Optional authentication handler.
108+
redirect_policy: Policy controlling which redirects are allowed.
109+
Defaults to ``RedirectPolicy.BLOCK_SCHEME_DOWNGRADE`` which blocks
110+
HTTPS-to-HTTP downgrades. Use ``RedirectPolicy.ENFORCE_HTTPS`` to
111+
only allow HTTPS destinations, or ``RedirectPolicy.ALLOW_ALL`` to
112+
disable redirect validation entirely (legacy behavior).
39113
40114
Returns:
41115
Configured httpx.AsyncClient instance with MCP defaults.
@@ -94,4 +168,12 @@ def create_mcp_http_client(
94168
if auth is not None: # pragma: no cover
95169
kwargs["auth"] = auth
96170

171+
# Install redirect validation hook
172+
if redirect_policy != RedirectPolicy.ALLOW_ALL:
173+
174+
async def check_redirect_hook(response: httpx.Response) -> None:
175+
await _check_redirect(response, redirect_policy)
176+
177+
kwargs["event_hooks"] = {"response": [check_redirect_hook]}
178+
97179
return httpx.AsyncClient(**kwargs)

tests/shared/test_httpx_utils.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Tests for httpx utility functions."""
22

33
import httpx
4+
import pytest
45

5-
from mcp.shared._httpx_utils import create_mcp_http_client
6+
from mcp.shared._httpx_utils import RedirectPolicy, _check_redirect, create_mcp_http_client
7+
8+
pytestmark = pytest.mark.anyio
69

710

811
def test_default_settings():
@@ -22,3 +25,142 @@ def test_custom_parameters():
2225

2326
assert client.headers["Authorization"] == "Bearer token"
2427
assert client.timeout.connect == 60.0
28+
29+
30+
def test_default_redirect_policy():
31+
"""Test that the default redirect policy is BLOCK_SCHEME_DOWNGRADE."""
32+
client = create_mcp_http_client()
33+
# Event hooks should be installed for the default policy
34+
assert len(client.event_hooks["response"]) == 1
35+
36+
37+
def test_allow_all_policy_no_hooks():
38+
"""Test that ALLOW_ALL does not install event hooks."""
39+
client = create_mcp_http_client(redirect_policy=RedirectPolicy.ALLOW_ALL)
40+
assert len(client.event_hooks["response"]) == 0
41+
42+
43+
# --- _check_redirect unit tests ---
44+
45+
46+
async def test_check_redirect_ignores_non_redirect():
47+
"""Test that non-redirect responses are ignored."""
48+
response = httpx.Response(200, request=httpx.Request("GET", "https://example.com"))
49+
# Should not raise
50+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
51+
await _check_redirect(response, RedirectPolicy.ENFORCE_HTTPS)
52+
53+
54+
async def test_check_redirect_ignores_redirect_without_next_request():
55+
"""Test that redirect responses without next_request are ignored."""
56+
response = httpx.Response(
57+
302,
58+
headers={"Location": "http://evil.com"},
59+
request=httpx.Request("GET", "https://example.com"),
60+
)
61+
# next_request is None on a manually constructed response
62+
assert response.next_request is None
63+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
64+
65+
66+
# --- BLOCK_SCHEME_DOWNGRADE tests ---
67+
68+
69+
async def test_block_scheme_downgrade_blocks_https_to_http():
70+
"""Test BLOCK_SCHEME_DOWNGRADE blocks HTTPS->HTTP redirect."""
71+
response = httpx.Response(
72+
302,
73+
headers={"Location": "http://evil.com"},
74+
request=httpx.Request("GET", "https://example.com"),
75+
)
76+
response.next_request = httpx.Request("GET", "http://evil.com")
77+
78+
with pytest.raises(httpx.HTTPStatusError, match="HTTPS-to-HTTP redirect blocked"):
79+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
80+
81+
82+
async def test_block_scheme_downgrade_allows_https_to_https():
83+
"""Test BLOCK_SCHEME_DOWNGRADE allows HTTPS->HTTPS redirect."""
84+
response = httpx.Response(
85+
302,
86+
headers={"Location": "https://other.com"},
87+
request=httpx.Request("GET", "https://example.com"),
88+
)
89+
response.next_request = httpx.Request("GET", "https://other.com")
90+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
91+
92+
93+
async def test_block_scheme_downgrade_allows_http_to_http():
94+
"""Test BLOCK_SCHEME_DOWNGRADE allows HTTP->HTTP redirect."""
95+
response = httpx.Response(
96+
302,
97+
headers={"Location": "http://other.com"},
98+
request=httpx.Request("GET", "http://example.com"),
99+
)
100+
response.next_request = httpx.Request("GET", "http://other.com")
101+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
102+
103+
104+
async def test_block_scheme_downgrade_allows_http_to_https():
105+
"""Test BLOCK_SCHEME_DOWNGRADE allows HTTP->HTTPS upgrade."""
106+
response = httpx.Response(
107+
302,
108+
headers={"Location": "https://other.com"},
109+
request=httpx.Request("GET", "http://example.com"),
110+
)
111+
response.next_request = httpx.Request("GET", "https://other.com")
112+
await _check_redirect(response, RedirectPolicy.BLOCK_SCHEME_DOWNGRADE)
113+
114+
115+
# --- ENFORCE_HTTPS tests ---
116+
117+
118+
async def test_enforce_https_blocks_http_target():
119+
"""Test ENFORCE_HTTPS blocks any HTTP redirect target."""
120+
response = httpx.Response(
121+
302,
122+
headers={"Location": "http://evil.com"},
123+
request=httpx.Request("GET", "https://example.com"),
124+
)
125+
response.next_request = httpx.Request("GET", "http://evil.com")
126+
127+
with pytest.raises(httpx.HTTPStatusError, match="Non-HTTPS redirect blocked"):
128+
await _check_redirect(response, RedirectPolicy.ENFORCE_HTTPS)
129+
130+
131+
async def test_enforce_https_blocks_http_to_http():
132+
"""Test ENFORCE_HTTPS blocks HTTP->HTTP redirect."""
133+
response = httpx.Response(
134+
302,
135+
headers={"Location": "http://other.com"},
136+
request=httpx.Request("GET", "http://example.com"),
137+
)
138+
response.next_request = httpx.Request("GET", "http://other.com")
139+
140+
with pytest.raises(httpx.HTTPStatusError, match="Non-HTTPS redirect blocked"):
141+
await _check_redirect(response, RedirectPolicy.ENFORCE_HTTPS)
142+
143+
144+
async def test_enforce_https_allows_https_target():
145+
"""Test ENFORCE_HTTPS allows HTTPS redirect target."""
146+
response = httpx.Response(
147+
302,
148+
headers={"Location": "https://other.com"},
149+
request=httpx.Request("GET", "https://example.com"),
150+
)
151+
response.next_request = httpx.Request("GET", "https://other.com")
152+
await _check_redirect(response, RedirectPolicy.ENFORCE_HTTPS)
153+
154+
155+
# --- ALLOW_ALL tests ---
156+
157+
158+
async def test_allow_all_permits_https_to_http():
159+
"""Test ALLOW_ALL permits HTTPS->HTTP redirect."""
160+
response = httpx.Response(
161+
302,
162+
headers={"Location": "http://evil.com"},
163+
request=httpx.Request("GET", "https://example.com"),
164+
)
165+
response.next_request = httpx.Request("GET", "http://evil.com")
166+
await _check_redirect(response, RedirectPolicy.ALLOW_ALL)

0 commit comments

Comments
 (0)