Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3e60ba4f | |||
| 360299f5f6 | |||
| d61e33113c | |||
| 5faf7cf45f | |||
| cd922fa750 | |||
| a4d4c386f7 | |||
| c8da826ef7 | |||
| 5166c2c4d7 | |||
| ec70e70a5d | |||
| 4a79b37714 | |||
| 76ae1c3603 | |||
| bb8a6200aa |
@@ -1,3 +1,16 @@
|
|||||||
|
## v0.50.2 (2025-12-13)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **news**: revert get_item() to use get_items() + filter
|
||||||
|
|
||||||
|
## v0.50.1 (2025-12-12)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Disable DNS rebinding protection for containerized deployments
|
||||||
|
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||||
|
|
||||||
## v0.50.0 (2025-12-11)
|
## v0.50.0 (2025-12-11)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:590cad70271b6c1795c6a11fb5c110efca593adbd0d4883cd19c36df6a56467b
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
# - Per-session app password authentication
|
# - Per-session app password authentication
|
||||||
# - Multi-user support via Smithery session config
|
# - Multi-user support via Smithery session config
|
||||||
|
|
||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:590cad70271b6c1795c6a11fb5c110efca593adbd0d4883cd19c36df6a56467b
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.50.0
|
version: 0.50.2
|
||||||
appVersion: "0.50.0"
|
appVersion: "0.50.2"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.2@sha256:04cc19547e586ac75e08dd056c11330d4ce4c5c561c89405b326180a37c19afb
|
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ class NewsClient(BaseNextcloudClient):
|
|||||||
async def get_item(self, item_id: int) -> dict[str, Any]:
|
async def get_item(self, item_id: int) -> dict[str, Any]:
|
||||||
"""Get a specific item by ID.
|
"""Get a specific item by ID.
|
||||||
|
|
||||||
|
Note: The News API doesn't have a direct single-item endpoint,
|
||||||
|
so we fetch all items and filter. For efficiency, consider
|
||||||
|
caching or using get_items with specific feed if known.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item_id: Item ID
|
item_id: Item ID
|
||||||
|
|
||||||
@@ -235,10 +239,15 @@ class NewsClient(BaseNextcloudClient):
|
|||||||
Item data
|
Item data
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPStatusError: 404 if item not found
|
ValueError: If item not found
|
||||||
"""
|
"""
|
||||||
response = await self._make_request("GET", f"{self.API_BASE}/items/{item_id}")
|
# Fetch all items and find the one we need
|
||||||
return response.json()
|
# This is inefficient but the API doesn't provide a direct endpoint
|
||||||
|
items = await self.get_items(batch_size=-1, get_read=True)
|
||||||
|
for item in items:
|
||||||
|
if item.get("id") == item_id:
|
||||||
|
return item
|
||||||
|
raise ValueError(f"Item {item_id} not found")
|
||||||
|
|
||||||
async def get_updated_items(
|
async def get_updated_items(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.50.0"
|
version = "0.50.2"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.22,<1.23)",
|
"mcp[cli] (>=1.23,<1.24)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=6.0.0,<7.0.0)",
|
||||||
|
|||||||
@@ -310,14 +310,16 @@ async def test_news_api_get_items_unread_only(mocker):
|
|||||||
|
|
||||||
|
|
||||||
async def test_news_api_get_item(mocker):
|
async def test_news_api_get_item(mocker):
|
||||||
"""Test that get_item fetches a single item by ID."""
|
"""Test that get_item fetches all items and filters for the requested ID."""
|
||||||
item = create_mock_news_item(item_id=123, title="Single Item")
|
# Create multiple items, only one should be returned
|
||||||
mock_response = create_mock_response(status_code=200, json_data=item)
|
items = [
|
||||||
|
create_mock_news_item(item_id=100, title="Other Item 1"),
|
||||||
|
create_mock_news_item(item_id=123, title="Single Item"),
|
||||||
|
create_mock_news_item(item_id=200, title="Other Item 2"),
|
||||||
|
]
|
||||||
|
|
||||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
mock_make_request = mocker.patch.object(
|
mock_get_items = mocker.patch.object(NewsClient, "get_items", return_value=items)
|
||||||
NewsClient, "_make_request", return_value=mock_response
|
|
||||||
)
|
|
||||||
|
|
||||||
client = NewsClient(mock_client, "testuser")
|
client = NewsClient(mock_client, "testuser")
|
||||||
result = await client.get_item(item_id=123)
|
result = await client.get_item(item_id=123)
|
||||||
@@ -325,7 +327,24 @@ async def test_news_api_get_item(mocker):
|
|||||||
assert result["id"] == 123
|
assert result["id"] == 123
|
||||||
assert result["title"] == "Single Item"
|
assert result["title"] == "Single Item"
|
||||||
|
|
||||||
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/items/123")
|
# Verify it fetched all items with correct params
|
||||||
|
mock_get_items.assert_called_once_with(batch_size=-1, get_read=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_news_api_get_item_not_found(mocker):
|
||||||
|
"""Test that get_item raises ValueError when item not found."""
|
||||||
|
items = [
|
||||||
|
create_mock_news_item(item_id=100, title="Item 1"),
|
||||||
|
create_mock_news_item(item_id=200, title="Item 2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mocker.patch.object(NewsClient, "get_items", return_value=items)
|
||||||
|
|
||||||
|
client = NewsClient(mock_client, "testuser")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Item 999 not found"):
|
||||||
|
await client.get_item(item_id=999)
|
||||||
|
|
||||||
|
|
||||||
async def test_news_api_get_updated_items(mocker):
|
async def test_news_api_get_updated_items(mocker):
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -1671,7 +1671,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.22.0"
|
version = "1.23.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -1689,9 +1689,9 @@ dependencies = [
|
|||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ 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 = [
|
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]
|
[package.optional-dependencies]
|
||||||
@@ -1962,7 +1962,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.50.0"
|
version = "0.50.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
@@ -2027,7 +2027,7 @@ requires-dist = [
|
|||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
||||||
{ name = "markdownify", specifier = ">=0.14.1" },
|
{ 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 = "openai", specifier = ">=2.8.1" },
|
||||||
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
|
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
|
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user