From ec70e70a5d5e806b3fd8ab751b4b1453616eeb2b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 12 Dec 2025 17:30:22 +0100 Subject: [PATCH] fix: Disable DNS rebinding protection for containerized deployments MCP Python SDK 1.23.0 introduced automatic DNS rebinding protection that auto-enables when host="127.0.0.1" (the default). This breaks containerized deployments (Kubernetes, Docker) because the protection rejects requests with Host headers like "nextcloud-mcp-server.default.svc.cluster.local:8000". Root cause: - FastMCP defaults to host="127.0.0.1" - SDK auto-enables DNS rebinding protection with allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"] - K8s/Docker requests use service DNS names or proxied hostnames - Protection middleware rejects these requests (421 Misdirected Request) Solution: - Explicitly pass transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) - Applied to all three FastMCP initializations (OAuth, Smithery, BasicAuth) - DNS rebinding attacks mitigated by OAuth authentication and network isolation This fixes issue #373 and enables MCP 1.23.x upgrade in PR #382. For detailed analysis, see docs/MCP-1.23-DNS-REBINDING-FIX.md --- docs/MCP-1.23-DNS-REBINDING-FIX.md | 104 +++++++++++++++++++++++++++++ nextcloud_mcp_server/app.py | 25 ++++++- 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 docs/MCP-1.23-DNS-REBINDING-FIX.md diff --git a/docs/MCP-1.23-DNS-REBINDING-FIX.md b/docs/MCP-1.23-DNS-REBINDING-FIX.md new file mode 100644 index 0000000..223f57c --- /dev/null +++ b/docs/MCP-1.23-DNS-REBINDING-FIX.md @@ -0,0 +1,104 @@ +# MCP 1.23.x DNS Rebinding Protection Fix + +## Problem + +MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled. + +### Root Cause + +From `mcp/server/fastmcp/server.py:177-183` in the Python SDK: + +```python +# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) +if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) +``` + +### What Was Happening + +1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters +2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None` +3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE +4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]` +5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts + +### Why `--host 0.0.0.0` Didn't Help + +The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns: + +- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens +- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic + +## Solution + +Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances. + +### Changes Made + +Modified `nextcloud_mcp_server/app.py`: + +1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security` +2. **Updated all three FastMCP initializations**: + - OAuth mode (line 1015) + - Smithery stateless mode (line 1030) + - BasicAuth mode (line 1040) + +Each now includes: +```python +transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False) +``` + +## Impact + +### ✅ What This Fixes + +- **Kubernetes deployments**: Requests with k8s service DNS names now work +- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work +- **Reverse proxy deployments**: Proxied requests with various Host headers now work +- **Ingress controllers**: Requests via ingress hostnames now work + +### 🔒 Security Considerations + +DNS rebinding protection defends against attacks where: +1. Attacker controls a DNS domain (e.g., `evil.com`) +2. DNS initially resolves to attacker's IP +3. After victim's browser caches the origin, DNS changes to victim's localhost +4. Attacker's page can now make requests to victim's localhost services + +**Why it's safe to disable for this deployment:** + +1. **OAuth authentication required** in production deployments (ADR-002, ADR-004) +2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks) +3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns) +4. **Host header validation inappropriate** for multi-tenant k8s environments + +If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list: + +```python +transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[ + "nextcloud-mcp-server.default.svc.cluster.local:*", + "mcp.example.com:*", + # Add all your expected Host header values + ] +) +``` + +## Testing + +- ✅ Ruff linting passes +- ✅ Type checking passes (pre-existing warnings unrelated) +- ✅ Module imports successfully +- ✅ Compatible with MCP 1.23.x + +## References + +- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0) +- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers" +- Issue #373 (original report of k8s breakage) +- PR #382 (MCP 1.23.x upgrade) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index b682e6a..9ac513c 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -19,6 +19,7 @@ import httpx from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import Context, FastMCP +from mcp.server.transport_security import TransportSecuritySettings from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.middleware.authentication import AuthenticationMiddleware @@ -1016,6 +1017,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = lifespan=oauth_lifespan, token_verifier=token_verifier, auth=auth_settings, + # Disable DNS rebinding protection for containerized deployments (k8s, Docker) + # MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False + ), ) else: # ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise @@ -1024,11 +1030,26 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # json_response=True returns plain JSON-RPC instead of SSE format, # required for Smithery scanner compatibility mcp = FastMCP( - "Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True + "Nextcloud MCP", + lifespan=app_lifespan_smithery, + json_response=True, + # Disable DNS rebinding protection for containerized deployments (k8s, Docker) + # MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False + ), ) else: logger.info("Configuring MCP server for BasicAuth mode") - mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic) + mcp = FastMCP( + "Nextcloud MCP", + lifespan=app_lifespan_basic, + # Disable DNS rebinding protection for containerized deployments (k8s, Docker) + # MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False + ), + ) @mcp.resource("nc://capabilities") async def nc_get_capabilities():