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(): diff --git a/pyproject.toml b/pyproject.toml index 739bf1d..de30c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"} requires-python = ">=3.11" keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"] dependencies = [ - "mcp[cli] (>=1.22,<1.23)", + "mcp[cli] (>=1.23,<1.24)", "httpx (>=0.28.1,<0.29.0)", "pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed "icalendar (>=6.0.0,<7.0.0)", diff --git a/tests/server/test_dns_rebinding_fix.py b/tests/server/test_dns_rebinding_fix.py new file mode 100644 index 0000000..83a3d3a --- /dev/null +++ b/tests/server/test_dns_rebinding_fix.py @@ -0,0 +1,174 @@ +""" +Test that DNS rebinding protection is properly disabled for containerized deployments. + +This test verifies that the fix for MCP 1.23.x DNS rebinding protection works correctly. +Without the fix, requests with Host headers that don't match the default allowed list +(127.0.0.1:*, localhost:*, [::1]:*) would be rejected with a 421 Misdirected Request error. +""" + +import httpx +import pytest + + +@pytest.mark.integration +async def test_accepts_various_host_headers(): + """Test that the MCP server accepts requests with various Host headers. + + This test simulates what happens in containerized deployments where the Host + header might be a k8s service DNS name, a proxied hostname, or other values + that don't match the default allowed list. + + Without the DNS rebinding protection fix, these requests would fail with: + - 421 Misdirected Request (for Host header mismatch) + - 403 Forbidden (for Origin header mismatch) + """ + mcp_url = "http://localhost:8000/mcp" + + # Test various Host headers that would be rejected by DNS rebinding protection + test_cases = [ + { + "name": "Kubernetes service DNS", + "headers": { + "Host": "nextcloud-mcp-server.default.svc.cluster.local:8000", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + }, + { + "name": "Custom domain", + "headers": { + "Host": "mcp.example.com:8000", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + }, + { + "name": "Proxied hostname", + "headers": { + "Host": "proxy.internal:8000", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + }, + { + "name": "Default localhost (should always work)", + "headers": { + "Host": "localhost:8000", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + }, + ] + + # Create a simple initialize request payload + initialize_request = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, + "id": 1, + } + + async with httpx.AsyncClient() as client: + for test_case in test_cases: + print(f"\n๐Ÿงช Testing: {test_case['name']}") + print(f" Host header: {test_case['headers']['Host']}") + + response = await client.post( + mcp_url, + json=initialize_request, + headers=test_case["headers"], + timeout=10.0, + ) + + # With DNS rebinding protection enabled (MCP 1.23 default), these would fail with: + # - 421 Misdirected Request (Host header not in allowed list) + # - 403 Forbidden (Origin header not in allowed list) + # + # With our fix (enable_dns_rebinding_protection=False), they should succeed + assert response.status_code in [200, 202], ( + f"Request failed for {test_case['name']}: " + f"status={response.status_code}, " + f"headers={test_case['headers']}, " + f"body={response.text[:200]}" + ) + + print(f" โœ… Status: {response.status_code}") + + # For SSE responses (status 200), verify we got SSE format + # For JSON responses (status 202), verify we got valid JSON + if response.status_code == 200: + # SSE response - should start with "event: message" or similar + response_text = response.text + assert "event:" in response_text or "data:" in response_text, ( + f"Expected SSE format for {test_case['name']}, got: {response_text[:200]}" + ) + print(" โœ… Received SSE stream response") + elif response.status_code == 202: + # JSON response for notifications + response_json = response.json() + assert "jsonrpc" in response_json or response_json is None, ( + f"Invalid response for {test_case['name']}: {response_json}" + ) + print(" โœ… Received JSON response") + + +@pytest.mark.integration +async def test_dns_rebinding_protection_is_disabled(): + """Verify that DNS rebinding protection is actually disabled in the configuration. + + This test makes a request that would DEFINITELY fail if DNS rebinding protection + was enabled with default settings (only allowing 127.0.0.1:*, localhost:*, [::1]:*). + """ + mcp_url = "http://localhost:8000/mcp" + + # Use a Host header that would NEVER be in the default allowed list + malicious_host = "evil.attacker.com:8000" + + initialize_request = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"}, + }, + "id": 1, + } + + async with httpx.AsyncClient() as client: + response = await client.post( + mcp_url, + json=initialize_request, + headers={ + "Host": malicious_host, + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + timeout=10.0, + ) + + # If DNS rebinding protection was enabled, this would return: + # - 421 Misdirected Request (Host header validation failed) + # + # Since we disabled it, this should succeed (status 200 or 202) + assert response.status_code in [200, 202], ( + f"DNS rebinding protection may still be enabled! " + f"Request with Host='{malicious_host}' was rejected: " + f"status={response.status_code}, body={response.text[:500]}" + ) + + # Verify we got a valid response (SSE or JSON) + if response.status_code == 200: + response_text = response.text + assert "event:" in response_text or "data:" in response_text, ( + f"Expected SSE format, got: {response_text[:200]}" + ) + + print("โœ… DNS rebinding protection is properly disabled") + print( + f" Request with Host '{malicious_host}' succeeded: {response.status_code}" + ) diff --git a/uv.lock b/uv.lock index c898e92..a95efee 100644 --- a/uv.lock +++ b/uv.lock @@ -1671,7 +1671,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.22.0" +version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1689,9 +1689,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/a2/c5ec0ab38b35ade2ae49a90fada718fbc76811dc5aa1760414c6aaa6b08a/mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01", size = 471788, upload-time = "2025-11-20T20:11:28.095Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a9/0e95530946408747ae200e86553ceda0dbd851d4ae9bbe0d02a69cbd6ad5/mcp-1.23.2.tar.gz", hash = "sha256:df4e4b7273dca2aaf428f9cf7a25bbac0c9007528a65004854b246aef3d157bc", size = 599953, upload-time = "2025-12-08T15:51:02.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/bb/711099f9c6bb52770f56e56401cdfb10da5b67029f701e0df29362df4c8e/mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98", size = 175489, upload-time = "2025-11-20T20:11:26.542Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6a/1a726905cf41a69d00989e8dfd9de7bd9b4a9f3c8723dac3077b0ba1a7b9/mcp-1.23.2-py3-none-any.whl", hash = "sha256:d8e4c6af0317ad954ea0a53dfb5e229dddea2d0a54568c080e82e8fae4a8264e", size = 231897, upload-time = "2025-12-08T15:51:01.023Z" }, ] [package.optional-dependencies] @@ -2027,7 +2027,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.6" }, { name = "langchain-text-splitters", specifier = ">=1.0.0" }, { name = "markdownify", specifier = ">=0.14.1" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.22,<1.23" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.23,<1.24" }, { name = "openai", specifier = ">=2.8.1" }, { name = "opentelemetry-api", specifier = ">=1.28.2" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },