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 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():
|
||||
|
||||
Reference in New Issue
Block a user