diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index 70560a3a4..16c9fadfa 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -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 diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index 5c295ce32..af6b862fe 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -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 diff --git a/fastmcp_slim/fastmcp/server/http.py b/fastmcp_slim/fastmcp/server/http.py index 690bdc9c8..8901cb99b 100644 --- a/fastmcp_slim/fastmcp/server/http.py +++ b/fastmcp_slim/fastmcp/server/http.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/mixins/transport.py b/fastmcp_slim/fastmcp/server/mixins/transport.py index 382677432..5376dd091 100644 --- a/fastmcp_slim/fastmcp/server/mixins/transport.py +++ b/fastmcp_slim/fastmcp/server/mixins/transport.py @@ -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. diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index 144b9023a..72cdbe5f2 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -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 diff --git a/tests/conformance/expected-failures.yml b/tests/conformance/expected-failures.yml index d00d4ed31..46b2081de 100644 --- a/tests/conformance/expected-failures.yml +++ b/tests/conformance/expected-failures.yml @@ -3,3 +3,4 @@ server: - server-sse-polling - resources-subscribe - resources-unsubscribe + - dns-rebinding-protection diff --git a/tests/server/http/test_http_auth_middleware.py b/tests/server/http/test_http_auth_middleware.py index a2cc99c23..6a2340f8a 100644 --- a/tests/server/http/test_http_auth_middleware.py +++ b/tests/server/http/test_http_auth_middleware.py @@ -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"], ) diff --git a/tests/server/test_transport.py b/tests/server/test_transport.py index b46372120..8f18861c8 100644 --- a/tests/server/test_transport.py +++ b/tests/server/test_transport.py @@ -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", diff --git a/tests/test_settings.py b/tests/test_settings.py index 8bf9cbc11..3052a0d35 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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"), [