Skip to content

Commit 5602e4c

Browse files
committed
fix: explain transport host rejections
1 parent 616476f commit 5602e4c

6 files changed

Lines changed: 75 additions & 7 deletions

File tree

README.md

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

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

13861408
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
@@ -122,7 +122,11 @@ async def test_sse_security_invalid_host_header(server_port: int):
122122
async with httpx.AsyncClient() as client:
123123
response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers)
124124
assert response.status_code == 421
125-
assert response.text == "Invalid Host header"
125+
assert response.json() == {
126+
"error": "host_not_allowed",
127+
"received_host": "evil.com",
128+
"configure": "TransportSecuritySettings.allowed_hosts",
129+
}
126130

127131
finally:
128132
process.terminate()
@@ -232,7 +236,11 @@ async def test_sse_security_custom_allowed_hosts(server_port: int):
232236
async with httpx.AsyncClient() as client:
233237
response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers)
234238
assert response.status_code == 421
235-
assert response.text == "Invalid Host header"
239+
assert response.json() == {
240+
"error": "host_not_allowed",
241+
"received_host": "evil.com",
242+
"configure": "TransportSecuritySettings.allowed_hosts",
243+
}
236244

237245
finally:
238246
process.terminate()

tests/server/test_streamable_http_security.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ async def test_streamable_http_security_invalid_host_header(server_port: int):
126126
headers=headers,
127127
)
128128
assert response.status_code == 421
129-
assert response.text == "Invalid Host header"
129+
assert response.json() == {
130+
"error": "host_not_allowed",
131+
"received_host": "evil.com",
132+
"configure": "TransportSecuritySettings.allowed_hosts",
133+
}
130134

131135
finally:
132136
process.terminate()
@@ -269,7 +273,11 @@ async def test_streamable_http_security_get_request(server_port: int):
269273
async with httpx.AsyncClient(timeout=5.0) as client:
270274
response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers)
271275
assert response.status_code == 421
272-
assert response.text == "Invalid Host header"
276+
assert response.json() == {
277+
"error": "host_not_allowed",
278+
"received_host": "evil.com",
279+
"configure": "TransportSecuritySettings.allowed_hosts",
280+
}
273281

274282
# Test GET request with valid host header
275283
headers = {

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)