Restore HTTP host guard compatibility (#4472)

This commit is contained in:
Jeremiah Lowin 2026-07-08 20:16:44 -04:00 committed by GitHub
commit 5fe4fae535
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 64 additions and 14 deletions

View file

@ -103,11 +103,11 @@ If you're mounting an authenticated server under a path prefix, see [Mounting Au
### Host and Origin Protection
FastMCP validates `Host` and browser `Origin` headers for Streamable HTTP requests automatically where it can infer a safe request boundary. This protects localhost-bound servers from DNS rebinding attacks while letting ASGI, serverless, and reverse-proxy deployments provide their public hostnames explicitly.
FastMCP can validate `Host` and browser `Origin` headers for Streamable HTTP requests before they reach MCP session handling. This request guard protects localhost-bound servers from DNS rebinding attacks, and it remains opt-in in FastMCP 3.x to preserve compatibility with existing ASGI, serverless, and reverse-proxy deployments.
Think of this as a request guard rather than CORS middleware. It decides whether a request can reach MCP session handling. CORS remains a separate browser response-header policy; configure CORS middleware separately when browser JavaScript must read cross-origin responses.
When you deploy behind a public hostname, add the hostname clients use to reach your MCP endpoint. This enables strict Host validation for that deployment. If a browser-based MCP client runs on a separate origin, add that origin as well:
Enable strict validation with `host_origin_protection=True`. When you deploy behind a public hostname, add the hostname clients use to reach your MCP endpoint. If a browser-based MCP client runs on a separate origin, add that origin as well:
```python
from fastmcp import FastMCP
@ -115,6 +115,7 @@ from fastmcp import FastMCP
mcp = FastMCP("My Server")
app = mcp.http_app(
host_origin_protection=True,
allowed_hosts=["mcp.example.com"],
allowed_origins=["https://app.example.com"],
)
@ -132,6 +133,7 @@ if __name__ == "__main__":
transport="http",
host="0.0.0.0",
port=8000,
host_origin_protection=True,
allowed_hosts=["mcp.example.com"],
allowed_origins=["https://app.example.com"],
)
@ -140,11 +142,12 @@ if __name__ == "__main__":
You can also configure these values with environment variables:
```bash
export FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=true
export FASTMCP_HTTP_ALLOWED_HOSTS='["mcp.example.com"]'
export FASTMCP_HTTP_ALLOWED_ORIGINS='["https://app.example.com"]'
```
Use `host_origin_protection=True` to require strict Host and Origin validation for every request. Use `host_origin_protection=False` only for trusted internal deployments that provide equivalent validation at another layer, such as an ingress proxy.
Use `host_origin_protection="auto"` to protect localhost-bound direct servers while allowing ASGI, serverless, and reverse-proxy deployments to keep their existing Host handling unless they configure explicit trust rules. Use `host_origin_protection=False` to keep the request guard disabled.
### Health Checks

View file

@ -42,9 +42,9 @@ These control how the server listens when running with an HTTP transport.
| `FASTMCP_STREAMABLE_HTTP_PATH` | `str` | `/mcp` | Path for Streamable HTTP endpoint. |
| `FASTMCP_STATELESS_HTTP` | `bool` | `false` | Enable stateless HTTP mode (new transport per request). Useful for multi-worker deployments. |
| `FASTMCP_JSON_RESPONSE` | `bool` | `false` | Use JSON responses instead of SSE for Streamable HTTP. |
| `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION` | `bool \| "auto"` | `auto` | Validate `Host` and browser `Origin` headers for Streamable HTTP requests. `auto` protects localhost-bound servers and explicit host/origin allowlists. |
| `FASTMCP_HTTP_ALLOWED_HOSTS` | `list[str] \| null` | `null` | Additional trusted hostnames for Streamable HTTP requests. Use a JSON array, such as `["mcp.example.com"]`. |
| `FASTMCP_HTTP_ALLOWED_ORIGINS` | `list[str] \| null` | `null` | Browser origins trusted by the Streamable HTTP request guard. Configure CORS separately for cross-origin browser reads. Use a JSON array, such as `["https://app.example.com"]`. |
| `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION` | `bool \| "auto"` | `false` | Validate `Host` and browser `Origin` headers for Streamable HTTP requests. `auto` protects localhost-bound servers and explicit host/origin allowlists. |
| `FASTMCP_HTTP_ALLOWED_HOSTS` | `list[str] \| null` | `null` | Additional trusted hostnames when Host and Origin protection is enabled. Use a JSON array, such as `["mcp.example.com"]`. |
| `FASTMCP_HTTP_ALLOWED_ORIGINS` | `list[str] \| null` | `null` | Browser origins trusted when Host and Origin protection is enabled. Configure CORS separately for cross-origin browser reads. Use a JSON array, such as `["https://app.example.com"]`. |
| `FASTMCP_DEBUG` | `bool` | `false` | Enable debug mode. |
## Error Handling

View file

@ -549,7 +549,7 @@ def create_streamable_http_app(
debug: bool = False,
routes: list[BaseRoute] | None = None,
middleware: list[Middleware] | None = None,
host_origin_protection: HostOriginProtection = "auto",
host_origin_protection: HostOriginProtection = False,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
) -> StarletteWithLifespan:
@ -569,8 +569,9 @@ def create_streamable_http_app(
routes: Optional list of custom routes
middleware: Optional list of middleware
host_origin_protection: Whether to validate Host and Origin headers
before requests reach the MCP endpoint. "auto" protects
localhost-bound servers and explicit host/origin allowlists.
before requests reach the MCP endpoint. Defaults to False for
compatibility. "auto" protects localhost-bound servers and explicit
host/origin allowlists.
allowed_hosts: Additional hostnames that may appear in the Host header.
allowed_origins: Additional browser origins trusted by the request guard.
Configure CORS separately when browser JavaScript must read

View file

@ -287,7 +287,8 @@ class TransportMixin:
stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http)
stateless: Alias for stateless_http for CLI consistency
host_origin_protection: Whether to validate Host and Origin headers
before requests reach the MCP endpoint. "auto" protects
before requests reach the MCP endpoint. Defaults to
settings.http_host_origin_protection. "auto" protects
localhost-bound servers and explicit host/origin allowlists.
allowed_hosts: Additional hostnames that may appear in the Host header.
allowed_origins: Additional browser origins trusted by the request guard.
@ -395,7 +396,8 @@ class TransportMixin:
disconnections. Requires event_store to be set. Only used with
streamable-http transport.
host_origin_protection: Whether to validate Host and Origin headers
before requests reach the MCP endpoint. "auto" protects
before requests reach the MCP endpoint. Defaults to
settings.http_host_origin_protection. "auto" protects
localhost-bound servers and explicit host/origin allowlists.
allowed_hosts: Additional hostnames that may appear in the Host header.
allowed_origins: Additional browser origins trusted by the request guard.

View file

@ -320,7 +320,7 @@ class Settings(BaseSettings):
stateless_http: bool = (
False # If True, uses true stateless mode (new transport per request)
)
http_host_origin_protection: bool | Literal["auto"] = "auto"
http_host_origin_protection: bool | Literal["auto"] = False
http_allowed_hosts: list[str] | None = None
http_allowed_origins: list[str] | None = None

View file

@ -3,3 +3,4 @@ server:
- server-sse-polling
- resources-subscribe
- resources-unsubscribe
- dns-rebinding-protection

View file

@ -166,6 +166,28 @@ class TestStreamableHTTPAppResourceMetadataURL:
class TestStreamableHTTPHostOriginProtection:
"""Test host and origin validation for streamable HTTP apps."""
def test_default_allows_untrusted_host_for_compatibility(self):
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
allowed_hosts=["apps.example.com"],
)
with TestClient(app, base_url="http://127.0.0.1") as client:
response = client.post(
"/mcp",
headers={
"accept": "application/json, text/event-stream",
"host": "internal-upstream",
"x-forwarded-host": "apps.example.com",
},
json=INITIALIZE_REQUEST,
)
assert response.status_code == 200
assert "mcp-session-id" in response.headers
async def test_auto_allows_public_host_when_server_scope_is_ambiguous(self):
status = await _guard_status(
host="mcp.example.com",
@ -216,11 +238,12 @@ class TestStreamableHTTPHostOriginProtection:
assert status == 200
def test_rejects_untrusted_host_before_session_initialization(self):
def test_auto_rejects_untrusted_host_before_session_initialization(self):
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
)
with TestClient(app, base_url="http://127.0.0.1") as client:
@ -236,11 +259,12 @@ class TestStreamableHTTPHostOriginProtection:
assert response.status_code == 421
assert "mcp-session-id" not in response.headers
def test_rejects_untrusted_origin_before_session_initialization(self):
def test_auto_rejects_untrusted_origin_before_session_initialization(self):
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
)
with TestClient(app, base_url="http://127.0.0.1") as client:
@ -261,6 +285,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
allowed_hosts=["mcp.example.com"],
allowed_origins=["https://app.example.com"],
)
@ -284,6 +309,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
allowed_hosts=["mcp.example.com"],
)
@ -305,6 +331,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
)
with TestClient(app, base_url="http://127.0.0.1") as client:
@ -325,6 +352,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
allowed_hosts=["mcp.example.com"],
)
@ -346,6 +374,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
allowed_hosts=["mcp.example.com"],
allowed_origins=["http://localhost:3000"],
)
@ -375,6 +404,7 @@ class TestStreamableHTTPHostOriginProtection:
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
host_origin_protection="auto",
allowed_hosts=["mcp.example.com"],
)

View file

@ -32,6 +32,15 @@ def test_resolve_allowed_hosts_for_run_merges_configured_hosts_with_loopback_hos
) == ["mcp.example.com", "127.0.0.1"]
def test_resolve_allowed_hosts_for_run_preserves_configured_hosts_when_disabled():
assert _resolve_allowed_hosts_for_run(
host="127.0.0.1",
host_origin_protection=False,
allowed_hosts=None,
configured_allowed_hosts=["mcp.example.com"],
) == ["mcp.example.com"]
def test_resolve_allowed_hosts_for_run_preserves_explicit_hosts():
assert _resolve_allowed_hosts_for_run(
host="127.0.0.1",

View file

@ -39,6 +39,10 @@ def test_get_setting_raises_for_missing_nested_parent():
assert str(exc_info.value) == "Setting missing does not exist."
def test_http_host_origin_protection_defaults_to_false():
assert Settings().http_host_origin_protection is False
@pytest.mark.parametrize(
("value", "expected"),
[