mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(examples): accept remote Host headers when bound off localhost
The streamable-http server bound to 0.0.0.0/a LAN IP answered TCP and /watch but returned 421 "Invalid Host header" on /mcp for every remote node — which broke multi-node play entirely. FastMCP freezes DNS-rebinding protection (a localhost-only Host allowlist) at CONSTRUCTION, and this module builds its FastMCP at import time with the default 127.0.0.1 host; flipping settings.host in _serve afterward never updated the frozen allowlist, so the LAN Host was always rejected. When UNDERSTONE_HOST is off localhost, drop the allowlist in _serve before run() — matching the SDK's own default for a non-localhost bind. The /mcp and /watch routes are unauthenticated by design, so serve only on a trusted network (documented). Regression test pins the mechanism: a default FastMCP 421s a foreign Host, a protection-disabled one accepts it. Tests 420 -> 421.
This commit is contained in:
@@ -237,3 +237,52 @@ def test_watch_routes_coexist_with_mcp(live_server: str) -> None:
|
||||
joined, watch_status = asyncio.run(_drive_both())
|
||||
assert "@" in joined # the MCP tool still returns a real frame
|
||||
assert watch_status == 200 # and the watch route still answers
|
||||
|
||||
|
||||
def test_streamable_http_host_gate_off_localhost() -> None:
|
||||
"""A non-localhost bind must accept remote `Host` headers on /mcp.
|
||||
|
||||
REGRESSION: FastMCP freezes DNS-rebinding protection (a localhost-only Host
|
||||
allowlist) at CONSTRUCTION, and ``server`` builds its FastMCP at import with
|
||||
the default 127.0.0.1 host. A 0.0.0.0/LAN bind therefore answered TCP and
|
||||
`/watch` but 421'd `/mcp` for every remote node ("Invalid Host header").
|
||||
``_serve`` drops the allowlist when bound off localhost; this pins the
|
||||
mechanism — a default instance rejects a foreign Host, a protection-disabled
|
||||
one accepts it (a 421 in the second case is the bug returning).
|
||||
|
||||
Uses fresh FastMCP instances (not the module singleton) so there is no
|
||||
shared-state or app-cache coupling with the live-server tests above.
|
||||
"""
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
foreign = {
|
||||
"Host": "192.168.0.239:8077",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
init = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "probe", "version": "0"},
|
||||
},
|
||||
}
|
||||
|
||||
# Default (localhost-baked allowlist) — a remote Host is refused.
|
||||
locked = FastMCP("hostgate-locked")
|
||||
with TestClient(locked.streamable_http_app()) as client:
|
||||
assert client.post("/mcp", headers=foreign, json=init).status_code == 421
|
||||
|
||||
# Protection disabled (what _serve does off localhost) — remote Host accepted.
|
||||
opened = FastMCP("hostgate-open")
|
||||
opened.settings.transport_security = TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
)
|
||||
with TestClient(opened.streamable_http_app()) as client:
|
||||
resp = client.post("/mcp", headers=foreign, json=init)
|
||||
assert resp.status_code != 421, f"remote Host still rejected: {resp.status_code} {resp.text}"
|
||||
|
||||
@@ -41,6 +41,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
|
||||
from understone import cli, sim, watch
|
||||
@@ -563,6 +564,18 @@ def _serve() -> None:
|
||||
mcp.settings.port = port
|
||||
mcp.settings.streamable_http_path = os.environ.get("UNDERSTONE_PATH", "/mcp")
|
||||
mcp.settings.stateless_http = False
|
||||
# FastMCP freezes DNS-rebinding protection (a localhost-only Host
|
||||
# allowlist) at CONSTRUCTION, and this module builds `mcp` at import
|
||||
# with the default 127.0.0.1 host — so a 0.0.0.0/LAN bind would 421
|
||||
# "Invalid Host" on /mcp for every remote node (the multi-node case).
|
||||
# Binding off localhost means we intend to accept other hosts, so drop
|
||||
# the allowlist here (matching the SDK's own default for a non-localhost
|
||||
# bind). These routes are unauthenticated by design — serve only on a
|
||||
# trusted network. UNDERSTONE_HOST controls the bind.
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
mcp.settings.transport_security = TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
)
|
||||
# The spectator page is only reachable over http, so its URL is composed
|
||||
# here from the bind address. A 0.0.0.0 bind should advertise a host a
|
||||
# browser can actually reach (see the README Watch section).
|
||||
|
||||
Reference in New Issue
Block a user