mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Restore HTTP host guard compatibility (#4472)
This commit is contained in:
parent
400db61b8b
commit
5fe4fae535
9 changed files with 64 additions and 14 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ server:
|
|||
- server-sse-polling
|
||||
- resources-subscribe
|
||||
- resources-unsubscribe
|
||||
- dns-rebinding-protection
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue