From bb8a6200aa915010b0a636e48a137ab102e0068e Mon Sep 17 00:00:00 2001 From: "renovate-bot-cbcoutinho[bot]" <210269379+renovate-bot-cbcoutinho[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:54:22 +0000 Subject: [PATCH 1/3] fix(deps): update dependency mcp to >=1.23,<1.24 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4f90e59..c57fe01 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/uv.lock b/uv.lock index 1e23d12..2e8591c 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" }, From ec70e70a5d5e806b3fd8ab751b4b1453616eeb2b Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 12 Dec 2025 17:30:22 +0100 Subject: [PATCH 2/3] 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(): From 5166c2c4d7b7cbc66987fe5e89fd7e92a79dc554 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 12 Dec 2025 17:56:16 +0100 Subject: [PATCH 3/3] test: Add verification test for DNS rebinding protection fix This test verifies that the MCP 1.23.x DNS rebinding protection fix works correctly by sending requests with various Host headers that would be rejected if the protection were enabled. Test cases: - Kubernetes service DNS (nextcloud-mcp-server.default.svc.cluster.local:8000) - Custom domain (mcp.example.com:8000) - Proxied hostname (proxy.internal:8000) - Default localhost (localhost:8000) - Malicious hostname (evil.attacker.com:8000) Without the fix (enable_dns_rebinding_protection=False), these would fail with: - 421 Misdirected Request (Host header not in allowed list) - 403 Forbidden (Origin header not in allowed list) With the fix, all requests succeed with 200 OK (SSE format). Test results: All 2 tests passed - test_accepts_various_host_headers: PASSED - test_dns_rebinding_protection_is_disabled: PASSED --- tests/server/test_dns_rebinding_fix.py | 174 +++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/server/test_dns_rebinding_fix.py 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}" + )