From 1707b2e6e18fb862cc370687af0d88f2369e9ba7 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 16 Feb 2026 09:21:21 +0100 Subject: [PATCH] feat: add self-signed SSL certificate support for Nextcloud connections Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure TLS certificate verification for all outbound Nextcloud connections. Centralizes SSL config via a new HTTP client factory (http.py) used by all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints, OAuth flows, and health checks. Closes #560 Co-Authored-By: Claude Opus 4.6 --- docs/configuration.md | 52 ++++++ env.sample | 13 ++ nextcloud_mcp_server/api/passwords.py | 10 +- nextcloud_mcp_server/api/webhooks.py | 11 +- nextcloud_mcp_server/app.py | 7 +- nextcloud_mcp_server/auth/astrolabe_client.py | 6 +- .../auth/browser_oauth_routes.py | 10 +- .../auth/client_registration.py | 6 +- nextcloud_mcp_server/auth/keycloak_oauth.py | 4 +- nextcloud_mcp_server/auth/oauth_routes.py | 11 +- nextcloud_mcp_server/auth/token_broker.py | 4 +- nextcloud_mcp_server/auth/token_exchange.py | 3 +- nextcloud_mcp_server/auth/unified_verifier.py | 4 +- nextcloud_mcp_server/auth/userinfo_routes.py | 7 +- nextcloud_mcp_server/auth/webhook_routes.py | 6 +- nextcloud_mcp_server/client/__init__.py | 4 +- nextcloud_mcp_server/client/calendar.py | 3 + nextcloud_mcp_server/config.py | 43 ++++- nextcloud_mcp_server/http.py | 45 +++++ nextcloud_mcp_server/server/oauth_tools.py | 5 +- tests/unit/test_ssl_config.py | 167 ++++++++++++++++++ 21 files changed, 383 insertions(+), 38 deletions(-) create mode 100644 nextcloud_mcp_server/http.py create mode 100644 tests/unit/test_ssl_config.py diff --git a/docs/configuration.md b/docs/configuration.md index f29fbdd..c965aa5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password --- +## SSL/TLS Configuration (Optional) + +If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification. + +### Custom CA Bundle (Recommended) + +Point the server at your CA certificate file: + +```dotenv +NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem +``` + +With Docker, mount the certificate as a read-only volume: + +```bash +docker run \ + -v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \ + -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \ + -e NEXTCLOUD_HOST=https://nextcloud.local \ + --env-file .env \ + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest +``` + +### Disable Verification (Development Only) + +> [!WARNING] +> Disabling TLS verification is insecure. Only use this for local development or testing. + +```dotenv +NEXTCLOUD_VERIFY_SSL=false +``` + +### Environment Variables Reference + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification | +| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities | + +### Scope + +These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including: + +- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.) +- OIDC discovery and token endpoints +- OAuth client registration (DCR) +- Health checks + +They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration. + +--- + ## Semantic Search Configuration (Optional) **New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution. diff --git a/env.sample b/env.sample index 3469ced..06524f2 100644 --- a/env.sample +++ b/env.sample @@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD= #CUSTOM_PROCESSOR_TIMEOUT=60 #CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png +# ===== SSL/TLS ===== +# For Nextcloud behind reverse proxies with self-signed or private CA certificates +# +# Disable TLS certificate verification (insecure, development only): +#NEXTCLOUD_VERIFY_SSL=false +# +# Use a custom CA bundle (path to PEM file): +#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem +# +# Docker example: mount the CA bundle as a volume +# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \ +# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ... + # ===== SECURITY & ADVANCED ===== # Cookie security (browser UI) # Auto-detects from NEXTCLOUD_HOST protocol if not set diff --git a/nextcloud_mcp_server/api/passwords.py b/nextcloud_mcp_server/api/passwords.py index c2e9ad2..78d201b 100644 --- a/nextcloud_mcp_server/api/passwords.py +++ b/nextcloud_mcp_server/api/passwords.py @@ -21,6 +21,8 @@ import httpx from starlette.requests import Request from starlette.responses import JSONResponse +from ..http import nextcloud_httpx_client + if TYPE_CHECKING: from nextcloud_mcp_server.auth.storage import RefreshTokenStorage @@ -252,7 +254,9 @@ async def provision_app_password(request: Request) -> JSONResponse: # Validate app password against Nextcloud try: - async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client: + async with nextcloud_httpx_client( + timeout=NEXTCLOUD_VALIDATION_TIMEOUT + ) as client: # Use OCS API to verify credentials test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" response = await client.get( @@ -380,7 +384,9 @@ async def delete_app_password(request: Request) -> JSONResponse: nextcloud_host = settings.nextcloud_host try: - async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client: + async with nextcloud_httpx_client( + timeout=NEXTCLOUD_VALIDATION_TIMEOUT + ) as client: test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user" response = await client.get( test_url, diff --git a/nextcloud_mcp_server/api/webhooks.py b/nextcloud_mcp_server/api/webhooks.py index 9626c6f..fe08917 100644 --- a/nextcloud_mcp_server/api/webhooks.py +++ b/nextcloud_mcp_server/api/webhooks.py @@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier import logging -import httpx from starlette.requests import Request from starlette.responses import JSONResponse @@ -20,6 +19,8 @@ from nextcloud_mcp_server.api.management import ( validate_token_and_get_user, ) +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -57,7 +58,7 @@ async def get_installed_apps(request: Request) -> JSONResponse: raise ValueError("Nextcloud host not configured") # Create authenticated HTTP client - async with httpx.AsyncClient( + async with nextcloud_httpx_client( base_url=nextcloud_host, headers={"Authorization": f"Bearer {token}"}, timeout=30.0, @@ -129,7 +130,7 @@ async def list_webhooks(request: Request) -> JSONResponse: raise ValueError("Nextcloud host not configured") # Create authenticated HTTP client - async with httpx.AsyncClient( + async with nextcloud_httpx_client( base_url=nextcloud_host, headers={"Authorization": f"Bearer {token}"}, timeout=30.0, @@ -210,7 +211,7 @@ async def create_webhook(request: Request) -> JSONResponse: raise ValueError("Nextcloud host not configured") # Create authenticated HTTP client - async with httpx.AsyncClient( + async with nextcloud_httpx_client( base_url=nextcloud_host, headers={"Authorization": f"Bearer {token}"}, timeout=30.0, @@ -286,7 +287,7 @@ async def delete_webhook(request: Request) -> JSONResponse: raise ValueError("Nextcloud host not configured") # Create authenticated HTTP client - async with httpx.AsyncClient( + async with nextcloud_httpx_client( base_url=nextcloud_host, headers={"Authorization": f"Bearer {token}"}, timeout=30.0, diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index cc1927b..9fcb49f 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -58,6 +58,7 @@ from nextcloud_mcp_server.config_validators import ( ) from nextcloud_mcp_server.context import get_client as get_nextcloud_client from nextcloud_mcp_server.document_processors import get_registry +from nextcloud_mcp_server.http import nextcloud_httpx_client from nextcloud_mcp_server.observability import ( ObservabilityMiddleware, setup_metrics, @@ -690,7 +691,7 @@ async def setup_oauth_config(): logger.info(f"Performing OIDC discovery: {discovery_url}") # Perform OIDC discovery - async with httpx.AsyncClient(follow_redirects=True) as client: + async with nextcloud_httpx_client(follow_redirects=True) as client: response = await client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -994,7 +995,7 @@ async def setup_oauth_config_for_multi_user_basic( # Perform OIDC discovery try: - async with httpx.AsyncClient( + async with nextcloud_httpx_client( timeout=30.0, follow_redirects=True ) as http_client: response = await http_client.get(discovery_url) @@ -1975,7 +1976,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Try to connect to Nextcloud start_time = time.time() try: - async with httpx.AsyncClient(timeout=2.0) as client: + async with nextcloud_httpx_client(timeout=2.0) as client: response = await client.get(f"{nextcloud_host}/status.php") duration = time.time() - start_time if response.status_code == 200: diff --git a/nextcloud_mcp_server/auth/astrolabe_client.py b/nextcloud_mcp_server/auth/astrolabe_client.py index 54d7080..c280a85 100644 --- a/nextcloud_mcp_server/auth/astrolabe_client.py +++ b/nextcloud_mcp_server/auth/astrolabe_client.py @@ -9,7 +9,7 @@ import logging import time from typing import Optional -import httpx +from ..http import nextcloud_httpx_client logger = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class AstrolabeClient: # Discover token endpoint discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration" - async with httpx.AsyncClient() as client: + async with nextcloud_httpx_client() as client: logger.debug(f"Discovering token endpoint from {discovery_url}") discovery_resp = await client.get(discovery_url) discovery_resp.raise_for_status() @@ -107,7 +107,7 @@ class AstrolabeClient: token = await self.get_access_token() url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}" - async with httpx.AsyncClient() as client: + async with nextcloud_httpx_client() as client: logger.debug(f"Retrieving app password for user: {user_id}") response = await client.get( diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index 1896136..4262595 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -22,6 +22,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import ( _query_idp_userinfo, ) +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -142,7 +144,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse: ) # Fetch authorization endpoint - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -286,7 +288,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo if code_verifier: token_params["code_verifier"] = code_verifier - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.post( oauth_client.token_endpoint, data=token_params, @@ -296,7 +298,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo else: # Integrated mode (Nextcloud OIDC) discovery_url = oauth_config.get("discovery_url") - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -314,7 +316,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo if code_verifier: token_params["code_verifier"] = code_verifier - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.post( token_endpoint, data=token_params, diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index 3931f31..9831144 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -10,6 +10,8 @@ import httpx from nextcloud_mcp_server.auth.storage import RefreshTokenStorage +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -132,7 +134,7 @@ async def register_client( logger.info(f"Registering OAuth client with Nextcloud: {client_name}") logger.debug(f"Registration endpoint: {registration_endpoint}") - async with httpx.AsyncClient(timeout=30.0) as client: + async with nextcloud_httpx_client(timeout=30.0) as client: try: response = await client.post( registration_endpoint, @@ -229,7 +231,7 @@ async def delete_client( logger.info(f"Deleting OAuth client: {client_id[:16]}...") logger.debug(f"Deletion endpoint: {deletion_endpoint}") - async with httpx.AsyncClient(timeout=30.0) as http_client: + async with nextcloud_httpx_client(timeout=30.0) as http_client: for attempt in range(max_retries): try: # Prefer RFC 7592 Bearer token authentication diff --git a/nextcloud_mcp_server/auth/keycloak_oauth.py b/nextcloud_mcp_server/auth/keycloak_oauth.py index c1fc5cf..5e840d8 100644 --- a/nextcloud_mcp_server/auth/keycloak_oauth.py +++ b/nextcloud_mcp_server/auth/keycloak_oauth.py @@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse import httpx +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -107,7 +109,7 @@ class KeycloakOAuthClient: async def _get_http_client(self) -> httpx.AsyncClient: """Get or create HTTP client""" if self._http_client is None: - self._http_client = httpx.AsyncClient(timeout=30.0) + self._http_client = nextcloud_httpx_client(timeout=30.0) return self._http_client async def close(self) -> None: diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index f4baec2..35f715d 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -27,7 +27,6 @@ import time from base64 import urlsafe_b64encode from urllib.parse import urlencode -import httpx import jwt from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse @@ -35,6 +34,8 @@ from starlette.responses import JSONResponse, RedirectResponse from nextcloud_mcp_server.auth.client_registry import get_client_registry from nextcloud_mcp_server.auth.storage import RefreshTokenStorage +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -218,7 +219,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: ) # Fetch authorization endpoint from discovery - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud( status_code=500, ) - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -462,7 +463,7 @@ async def oauth_callback_nextcloud(request: Request): callback_uri = f"{mcp_server_url}/oauth/callback" discovery_url = oauth_config.get("discovery_url") - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -482,7 +483,7 @@ async def oauth_callback_nextcloud(request: Request): token_params["code_verifier"] = code_verifier # Exchange code for tokens - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: response = await http_client.post( token_endpoint, data=token_params, diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index 1c8fadc..b14478b 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -25,6 +25,8 @@ import jwt from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -136,7 +138,7 @@ class TokenBrokerService: async def _get_http_client(self) -> httpx.AsyncClient: """Get or create HTTP client.""" if self._http_client is None: - self._http_client = httpx.AsyncClient( + self._http_client = nextcloud_httpx_client( timeout=httpx.Timeout(30.0), follow_redirects=True ) return self._http_client diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py index 4ccc800..52be1ab 100644 --- a/nextcloud_mcp_server/auth/token_exchange.py +++ b/nextcloud_mcp_server/auth/token_exchange.py @@ -20,6 +20,7 @@ import httpx import jwt from ..config import get_settings +from ..http import nextcloud_httpx_client from .storage import RefreshTokenStorage logger = logging.getLogger(__name__) @@ -68,7 +69,7 @@ class TokenExchangeService: self.storage: Optional[RefreshTokenStorage] = None # Create HTTP client - self.http_client = httpx.AsyncClient( + self.http_client = nextcloud_httpx_client( timeout=30.0, follow_redirects=True, ) diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index fd35731..7a19722 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import ( record_oauth_token_validation, ) +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier): self.mode = "exchange" if settings.enable_token_exchange else "multi-audience" # Common components for all modes - self.http_client = httpx.AsyncClient(timeout=10.0) + self.http_client = nextcloud_httpx_client(timeout=10.0) # JWT verification support self.jwks_client: PyJWKClient | None = None diff --git a/nextcloud_mcp_server/auth/userinfo_routes.py b/nextcloud_mcp_server/auth/userinfo_routes.py index 5491d35..635a8af 100644 --- a/nextcloud_mcp_server/auth/userinfo_routes.py +++ b/nextcloud_mcp_server/auth/userinfo_routes.py @@ -13,7 +13,6 @@ import traceback from pathlib import Path from typing import Any -import httpx from jinja2 import Environment, FileSystemLoader from starlette.authentication import requires from starlette.requests import Request @@ -22,6 +21,8 @@ from starlette.responses import HTMLResponse, JSONResponse from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import get_settings +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) # Setup Jinja2 environment for templates @@ -257,7 +258,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None: return None try: - async with httpx.AsyncClient(timeout=10.0) as client: + async with nextcloud_httpx_client(timeout=10.0) as client: response = await client.get(discovery_url) response.raise_for_status() discovery = response.json() @@ -290,7 +291,7 @@ async def _query_idp_userinfo( User info dictionary from IdP, or None if query fails """ try: - async with httpx.AsyncClient(timeout=10.0) as client: + async with nextcloud_httpx_client(timeout=10.0) as client: response = await client.get( userinfo_uri, headers={"Authorization": f"Bearer {access_token_str}"}, diff --git a/nextcloud_mcp_server/auth/webhook_routes.py b/nextcloud_mcp_server/auth/webhook_routes.py index 35446e0..e7ee3c6 100644 --- a/nextcloud_mcp_server/auth/webhook_routes.py +++ b/nextcloud_mcp_server/auth/webhook_routes.py @@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import ( get_preset, ) +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: assert nextcloud_host is not None # Type narrowing for type checker assert username is not None and password is not None # Type narrowing - return httpx.AsyncClient( + return nextcloud_httpx_client( base_url=nextcloud_host, auth=(username, password), timeout=30.0, @@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: if not nextcloud_host: raise RuntimeError("Nextcloud host not configured") - return httpx.AsyncClient( + return nextcloud_httpx_client( base_url=nextcloud_host, headers={"Authorization": f"Bearer {access_token}"}, timeout=30.0, diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 3a5a6e1..bf176f8 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -4,7 +4,6 @@ import os from httpx import ( AsyncBaseTransport, AsyncClient, - AsyncHTTPTransport, Auth, BasicAuth, Request, @@ -13,6 +12,7 @@ from httpx import ( ) from ..controllers.notes_search import NotesSearchController +from ..http import nextcloud_httpx_transport from .calendar import CalendarClient from .contacts import ContactsClient from .cookbook import CookbookClient @@ -67,7 +67,7 @@ class NextcloudClient: self._client = AsyncClient( base_url=base_url, auth=auth, - transport=AsyncDisableCookieTransport(AsyncHTTPTransport()), + transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()), event_hooks={"request": [log_request], "response": [log_response]}, timeout=Timeout(timeout=30, connect=5), ) diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index e2c4a5a..3a4cc1a 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -13,6 +13,8 @@ from icalendar import Alarm, Calendar, vRecur from icalendar import Event as ICalEvent from icalendar import Todo as ICalTodo +from ..config import get_nextcloud_ssl_verify + logger = logging.getLogger(__name__) @@ -34,6 +36,7 @@ class CalendarClient: url=f"{base_url}/remote.php/dav/", username=username, auth=auth, + ssl_verify_cert=get_nextcloud_ssl_verify(), ) self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/" diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index fe7c436..d5a467c 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -181,6 +181,10 @@ class Settings: nextcloud_username: Optional[str] = None nextcloud_password: Optional[str] = None + # Nextcloud SSL/TLS settings + nextcloud_verify_ssl: bool = True + nextcloud_ca_bundle: Optional[str] = None + # ADR-005: Token Audience Validation (required for OAuth mode) nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience) nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier @@ -252,9 +256,25 @@ class Settings: log_include_trace_context: bool = True def __post_init__(self): - """Validate Qdrant configuration and set defaults.""" + """Validate configuration and set defaults.""" logger = logging.getLogger(__name__) + # Validate SSL/TLS configuration + if not self.nextcloud_verify_ssl: + logger.warning( + "NEXTCLOUD_VERIFY_SSL is disabled. " + "TLS certificate verification is turned off for all Nextcloud connections. " + "This is insecure and should only be used for development/testing." + ) + if self.nextcloud_ca_bundle: + import os as _os + + if not _os.path.isfile(self.nextcloud_ca_bundle): + raise ValueError( + f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}" + ) + logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle) + # Ensure mutual exclusivity if self.qdrant_url and self.qdrant_location: raise ValueError( @@ -504,6 +524,11 @@ def get_settings() -> Settings: nextcloud_host=os.getenv("NEXTCLOUD_HOST"), nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), + # Nextcloud SSL/TLS settings + nextcloud_verify_ssl=( + os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true" + ), + nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"), # ADR-005: Token Audience Validation nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"), nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"), @@ -569,3 +594,19 @@ def get_settings() -> Settings: log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower() == "true", ) + + +def get_nextcloud_ssl_verify() -> bool | str: + """Return the SSL verification setting for Nextcloud connections. + + Returns: + - False if NEXTCLOUD_VERIFY_SSL=false (disable verification) + - CA bundle path if NEXTCLOUD_CA_BUNDLE is set (custom CA) + - True otherwise (default system CA verification) + """ + settings = get_settings() + if not settings.nextcloud_verify_ssl: + return False + if settings.nextcloud_ca_bundle: + return settings.nextcloud_ca_bundle + return True diff --git a/nextcloud_mcp_server/http.py b/nextcloud_mcp_server/http.py new file mode 100644 index 0000000..676ec8f --- /dev/null +++ b/nextcloud_mcp_server/http.py @@ -0,0 +1,45 @@ +"""Centralized HTTP client factory for Nextcloud connections. + +All outbound connections to Nextcloud (API calls, OIDC endpoints) should use +these factories to ensure consistent SSL/TLS configuration from environment +variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE). +""" + +from typing import Any + +import httpx + +from .config import get_nextcloud_ssl_verify + + +def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient: + """Create an httpx.AsyncClient with Nextcloud SSL settings applied. + + Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment + via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg + takes precedence if explicitly provided. + + Args: + **kwargs: Forwarded to ``httpx.AsyncClient()``. + + Returns: + Configured ``httpx.AsyncClient``. + """ + kwargs.setdefault("verify", get_nextcloud_ssl_verify()) + return httpx.AsyncClient(**kwargs) + + +def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport: + """Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied. + + Used by ``NextcloudClient`` which wraps the transport in + ``AsyncDisableCookieTransport``. + + Args: + **kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``. + + Returns: + Configured ``httpx.AsyncHTTPTransport``. + """ + kwargs.setdefault("verify", get_nextcloud_ssl_verify()) + return httpx.AsyncHTTPTransport(**kwargs) diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 77af316..2d06056 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -11,7 +11,6 @@ import secrets from typing import Optional from urllib.parse import urlencode -import httpx import jwt from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import AccessToken @@ -24,6 +23,8 @@ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.token_broker import TokenBrokerService from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo +from ..http import nextcloud_httpx_client + logger = logging.getLogger(__name__) @@ -69,7 +70,7 @@ async def extract_user_id_from_token(ctx: Context) -> str: "OIDC_DISCOVERY_URI", "http://localhost:8080/.well-known/openid-configuration", ) - async with httpx.AsyncClient() as http_client: + async with nextcloud_httpx_client() as http_client: discovery_response = await http_client.get(oidc_discovery_uri) discovery_response.raise_for_status() discovery = discovery_response.json() diff --git a/tests/unit/test_ssl_config.py b/tests/unit/test_ssl_config.py new file mode 100644 index 0000000..7e366af --- /dev/null +++ b/tests/unit/test_ssl_config.py @@ -0,0 +1,167 @@ +"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).""" + +import logging +import os +from unittest.mock import patch + +import httpx +import pytest + +from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings +from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport + + +class TestSSLSettings: + """Test SSL/TLS fields on Settings dataclass.""" + + def test_defaults(self): + """verify_ssl defaults to True, ca_bundle defaults to None.""" + settings = Settings() + assert settings.nextcloud_verify_ssl is True + assert settings.nextcloud_ca_bundle is None + + def test_verify_ssl_false_logs_warning(self, caplog): + caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") + Settings(nextcloud_verify_ssl=False) + assert "NEXTCLOUD_VERIFY_SSL is disabled" in caplog.text + + def test_ca_bundle_nonexistent_path_raises(self): + with pytest.raises(ValueError, match="does not exist"): + Settings(nextcloud_ca_bundle="/nonexistent/path/ca.pem") + + def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path): + ca_file = tmp_path / "ca.pem" + ca_file.write_text( + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n" + ) + caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config") + settings = Settings(nextcloud_ca_bundle=str(ca_file)) + assert settings.nextcloud_ca_bundle == str(ca_file) + assert "Using custom CA bundle" in caplog.text + + +class TestGetNextcloudSSLVerify: + """Test the get_nextcloud_ssl_verify() helper function.""" + + def test_default_returns_true(self): + env = { + "NEXTCLOUD_VERIFY_SSL": "true", + } + with patch.dict(os.environ, env, clear=False): + # Clear any cached settings + result = get_nextcloud_ssl_verify() + assert result is True + + def test_verify_false_returns_false(self): + env = { + "NEXTCLOUD_VERIFY_SSL": "false", + } + with patch.dict(os.environ, env, clear=False): + with patch( + "nextcloud_mcp_server.config.get_settings", + return_value=Settings(nextcloud_verify_ssl=False), + ): + result = get_nextcloud_ssl_verify() + assert result is False + + def test_ca_bundle_returns_path(self, tmp_path): + ca_file = tmp_path / "ca.pem" + ca_file.write_text( + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n" + ) + with patch( + "nextcloud_mcp_server.config.get_settings", + return_value=Settings(nextcloud_ca_bundle=str(ca_file)), + ): + result = get_nextcloud_ssl_verify() + assert result == str(ca_file) + + def test_verify_false_takes_precedence_over_ca_bundle(self, tmp_path): + """When verify_ssl=False, ca_bundle is ignored (False wins).""" + ca_file = tmp_path / "ca.pem" + ca_file.write_text( + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n" + ) + with patch( + "nextcloud_mcp_server.config.get_settings", + return_value=Settings( + nextcloud_verify_ssl=False, + nextcloud_ca_bundle=str(ca_file), + ), + ): + result = get_nextcloud_ssl_verify() + assert result is False + + +class TestGetSettingsSSLEnvVars: + """Test that get_settings() reads SSL env vars correctly.""" + + def test_verify_ssl_env_true(self): + env = {"NEXTCLOUD_VERIFY_SSL": "true"} + with patch.dict(os.environ, env, clear=False): + settings = get_settings() + assert settings.nextcloud_verify_ssl is True + + def test_verify_ssl_env_false(self): + env = {"NEXTCLOUD_VERIFY_SSL": "false"} + with patch.dict(os.environ, env, clear=False): + settings = get_settings() + assert settings.nextcloud_verify_ssl is False + + def test_verify_ssl_env_missing_defaults_true(self): + with patch.dict(os.environ, {}, clear=False): + # Remove NEXTCLOUD_VERIFY_SSL if it exists + os.environ.pop("NEXTCLOUD_VERIFY_SSL", None) + settings = get_settings() + assert settings.nextcloud_verify_ssl is True + + def test_ca_bundle_env(self, tmp_path): + ca_file = tmp_path / "ca.pem" + ca_file.write_text( + "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n" + ) + env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)} + with patch.dict(os.environ, env, clear=False): + settings = get_settings() + assert settings.nextcloud_ca_bundle == str(ca_file) + + +class TestHTTPClientFactory: + """Test that factory functions apply verify correctly.""" + + def test_client_applies_verify_true(self): + with patch( + "nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True + ): + client = nextcloud_httpx_client() + # httpx stores verify as an SSLConfig; check the _transport + assert isinstance(client, httpx.AsyncClient) + + def test_client_applies_verify_false(self): + with patch( + "nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False + ): + client = nextcloud_httpx_client() + assert isinstance(client, httpx.AsyncClient) + + def test_client_caller_override_takes_precedence(self): + """Caller-supplied verify kwarg should not be overridden.""" + with patch( + "nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True + ): + client = nextcloud_httpx_client(verify=False) + assert isinstance(client, httpx.AsyncClient) + + def test_transport_applies_verify(self): + with patch( + "nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False + ): + transport = nextcloud_httpx_transport() + assert isinstance(transport, httpx.AsyncHTTPTransport) + + def test_client_passes_extra_kwargs(self): + with patch( + "nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True + ): + client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True) + assert isinstance(client, httpx.AsyncClient)