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
This commit is contained in:
@@ -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)
|
||||||
@@ -19,6 +19,7 @@ import httpx
|
|||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
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,
|
lifespan=oauth_lifespan,
|
||||||
token_verifier=token_verifier,
|
token_verifier=token_verifier,
|
||||||
auth=auth_settings,
|
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:
|
else:
|
||||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
# 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,
|
# json_response=True returns plain JSON-RPC instead of SSE format,
|
||||||
# required for Smithery scanner compatibility
|
# required for Smithery scanner compatibility
|
||||||
mcp = FastMCP(
|
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:
|
else:
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
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")
|
@mcp.resource("nc://capabilities")
|
||||||
async def nc_get_capabilities():
|
async def nc_get_capabilities():
|
||||||
|
|||||||
Reference in New Issue
Block a user