Skip to content

Commit b737756

Browse files
committed
fix: explain transport host rejections
1 parent ac96f88 commit b737756

6 files changed

Lines changed: 75 additions & 7 deletions

File tree

README.v2.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,28 @@ This configuration is necessary because:
13751375
- Browsers restrict access to response headers unless explicitly exposed via CORS
13761376
- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses
13771377

1378+
#### Reverse Proxy Host Headers
1379+
1380+
DNS rebinding protection checks the incoming `Host` header when transport security is enabled. If your server is behind
1381+
nginx, Cloudflare, or another reverse proxy, include the public hostname in `TransportSecuritySettings.allowed_hosts`.
1382+
Some proxies preserve the port, so include both forms when needed:
1383+
1384+
```python
1385+
from mcp.server.transport_security import TransportSecuritySettings
1386+
1387+
transport_security = TransportSecuritySettings(
1388+
allowed_hosts=[
1389+
"mcp.example.com",
1390+
"mcp.example.com:443",
1391+
],
1392+
)
1393+
1394+
mcp_app = server.streamable_http_app(transport_security=transport_security)
1395+
```
1396+
1397+
If a request is rejected by this check, the server returns HTTP 421 with `host_not_allowed`, the received host, and the
1398+
setting to configure.
1399+
13781400
### Mounting to an Existing ASGI Server
13791401

13801402
By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below.

src/mcp/server/transport_security.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from pydantic import BaseModel, Field
66
from starlette.requests import Request
7-
from starlette.responses import Response
7+
from starlette.responses import JSONResponse, Response
88

99
logger = logging.getLogger(__name__)
1010

@@ -106,7 +106,14 @@ async def validate_request(self, request: Request, is_post: bool = False) -> Res
106106
# Validate Host header
107107
host = request.headers.get("host")
108108
if not self._validate_host(host):
109-
return Response("Invalid Host header", status_code=421)
109+
return JSONResponse(
110+
{
111+
"error": "host_not_allowed",
112+
"received_host": host,
113+
"configure": "TransportSecuritySettings.allowed_hosts",
114+
},
115+
status_code=421,
116+
)
110117

111118
# Validate Origin header
112119
origin = request.headers.get("origin")

tests/interaction/transports/test_hosting_http.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,16 @@ async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> No
330330
assert [event async for event in ok.aiter_sse()]
331331

332332
assert (bad_origin.status_code, bad_origin.text) == snapshot((403, "Invalid Origin header"))
333-
assert (bad_host.status_code, bad_host.text) == snapshot((421, "Invalid Host header"))
333+
assert (bad_host.status_code, bad_host.json()) == snapshot(
334+
(
335+
421,
336+
{
337+
"error": "host_not_allowed",
338+
"received_host": "evil.example",
339+
"configure": "TransportSecuritySettings.allowed_hosts",
340+
},
341+
)
342+
)
334343

335344
async with mounted_app(
336345
Server("unguarded"), transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)

tests/server/test_sse_security.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ async def test_sse_security_invalid_host_header() -> None:
8686
async with sse_security_client(security_settings) as client:
8787
response = await client.get("/sse", headers={"Host": "evil.com"})
8888
assert response.status_code == 421
89-
assert response.text == "Invalid Host header"
89+
assert response.json() == {
90+
"error": "host_not_allowed",
91+
"received_host": "evil.com",
92+
"configure": "TransportSecuritySettings.allowed_hosts",
93+
}
9094

9195

9296
@pytest.mark.anyio
@@ -149,7 +153,11 @@ async def test_sse_security_custom_allowed_hosts() -> None:
149153

150154
response = await client.get("/sse", headers={"Host": "evil.com"})
151155
assert response.status_code == 421
152-
assert response.text == "Invalid Host header"
156+
assert response.json() == {
157+
"error": "host_not_allowed",
158+
"received_host": "evil.com",
159+
"configure": "TransportSecuritySettings.allowed_hosts",
160+
}
153161

154162

155163
@pytest.mark.anyio

tests/server/test_streamable_http_security.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ async def test_streamable_http_security_invalid_host_header() -> None:
6060
async with streamable_http_security_client(security_settings) as client:
6161
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"})
6262
assert response.status_code == 421
63-
assert response.text == "Invalid Host header"
63+
assert response.json() == {
64+
"error": "host_not_allowed",
65+
"received_host": "evil.com",
66+
"configure": "TransportSecuritySettings.allowed_hosts",
67+
}
6468

6569

6670
@pytest.mark.anyio
@@ -121,7 +125,11 @@ async def test_streamable_http_security_get_request() -> None:
121125
async with streamable_http_security_client(security_settings) as client:
122126
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "evil.com"})
123127
assert response.status_code == 421
124-
assert response.text == "Invalid Host header"
128+
assert response.json() == {
129+
"error": "host_not_allowed",
130+
"received_host": "evil.com",
131+
"configure": "TransportSecuritySettings.allowed_hosts",
132+
}
125133

126134
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"})
127135
# An allowed host passes security and fails on session validation instead.

tests/server/test_transport_security.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ async def test_validate_request_checks_host_then_origin(
4848
assert (None if response is None else response.status_code) == expected
4949

5050

51+
@pytest.mark.anyio
52+
async def test_validate_request_explains_host_rejection() -> None:
53+
middleware = TransportSecurityMiddleware(SETTINGS)
54+
response = await middleware.validate_request(_request("evil.example", None))
55+
56+
assert response is not None
57+
assert response.status_code == 421
58+
assert response.media_type == "application/json"
59+
assert response.body == (
60+
b'{"error":"host_not_allowed","received_host":"evil.example",'
61+
b'"configure":"TransportSecuritySettings.allowed_hosts"}'
62+
)
63+
64+
5165
@pytest.mark.anyio
5266
async def test_validate_request_skips_host_and_origin_when_protection_is_disabled() -> None:
5367
"""With DNS-rebinding protection off, any Host/Origin is accepted."""

0 commit comments

Comments
 (0)