diff --git a/.dockerignore b/.dockerignore index bd5793c9b..f1d8f5d52 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,4 @@ !README.md !uv.lock -!nextcloud_mcp_server/**/*.py -!nextcloud_mcp_server/**/*.html -!nextcloud_mcp_server/auth/static/* +!nextcloud_mcp_server/ diff --git a/Dockerfile b/Dockerfile index c5da1103b..c4deeb619 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d RUN apt update && apt install --no-install-recommends --no-install-suggests -y \ git \ tesseract-ocr \ - sqlite3 && apt clean + sqlite3 && apt clean && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -20,6 +20,10 @@ COPY . . RUN uv sync --locked --no-dev --no-editable --no-cache +RUN adduser --disabled-password --gecos "" --uid 10001 appuser && \ + chown -R appuser:appuser /app +USER appuser + ENV PYTHONUNBUFFERED=1 ENV VIRTUAL_ENV=/app/.venv ENV PATH=/app/.venv/bin:$PATH diff --git a/Dockerfile.smithery b/Dockerfile.smithery index 047837637..860ac6139 100644 --- a/Dockerfile.smithery +++ b/Dockerfile.smithery @@ -23,13 +23,17 @@ COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d # 1. git (required for caldav dependency from git) # 2. sqlite for development with token db RUN apt update && apt install --no-install-recommends --no-install-suggests -y \ - git + git && apt clean && rm -rf /var/lib/apt/lists/* # Copy project files COPY . . RUN uv sync --locked --no-dev --no-editable --no-cache +RUN adduser --disabled-password --gecos "" --uid 10001 appuser && \ + chown -R appuser:appuser /app +USER appuser + # Set Smithery mode environment variables ENV SMITHERY_DEPLOYMENT=true ENV VECTOR_SYNC_ENABLED=false diff --git a/README.md b/README.md index 1d4a5a7a5..acea64714 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ The server supports four authentication modes: **Multi-User (BasicAuth Pass-Through):** - MCP clients send credentials via Authorization header - Server passes through to Nextcloud (stateless by default) +- Optional per-user scope restrictions via `BASIC_AUTH_SCOPES_` env vars - Optional offline access for background operations (`ENABLE_MULTI_USER_BASIC_AUTH=true`) - Best for: Multi-user setups without OAuth infrastructure diff --git a/docs/authentication.md b/docs/authentication.md index 20c210b31..42b9974bf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -170,6 +170,12 @@ When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the # Enable multi-user BasicAuth ENABLE_MULTI_USER_BASIC_AUTH=true +# Per-user scope restrictions (optional) +# Omit a user's variable to grant full access (backward compatible) +BASIC_AUTH_SCOPES_CLAUDE=files:read,files:write,notes:read +BASIC_AUTH_SCOPES_N8N=calendar:read,calendar:write +# BASIC_AUTH_SCOPES_ADMIN not set → full access + # Enable hybrid mode (OAuth provisioning for management APIs) ENABLE_OFFLINE_ACCESS=true diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 8a8523ca4..ffd44f8f1 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -97,6 +97,7 @@ from nextcloud_mcp_server.config import ( DeploymentMode, Settings, + get_basic_auth_scopes, get_document_processor_config, get_settings, ) @@ -456,6 +457,36 @@ async def __call__( logger.debug( f"BasicAuth credentials extracted for user: {username}" ) + + # Inject synthetic AccessToken if scopes are configured + # for this user, enabling @require_scopes checks and + # list_tools_filtered() to work in BasicAuth mode + configured_scopes = get_basic_auth_scopes(username) + if configured_scopes is not None: + from mcp.server.auth.middleware.auth_context import ( # noqa: PLC0415 + auth_context_var, + ) + from mcp.server.auth.middleware.bearer_auth import ( # noqa: PLC0415 + AuthenticatedUser, + ) + from mcp.server.auth.provider import ( # noqa: PLC0415 + AccessToken, + ) + + synthetic_token = AccessToken( + token="basic-auth-synthetic", + client_id=f"basic-auth:{username}", + scopes=configured_scopes, + ) + ctx_token = auth_context_var.set( + AuthenticatedUser(synthetic_token) + ) + try: + await self.app(scope, receive, send) + finally: + auth_context_var.reset(ctx_token) + return + except Exception as e: logger.warning(f"Failed to extract BasicAuth credentials: {e}") @@ -1480,8 +1511,9 @@ async def nc_get_capabilities(): logger.info("Registering Login Flow v2 auth tools") register_auth_tools(mcp) - # Override list_tools to filter based on user's token scopes (OAuth mode only) - if oauth_enabled: + # Override list_tools to filter based on user's token scopes + # Active in OAuth mode and when BasicAuth users have scope configuration + if oauth_enabled or settings.enable_multi_user_basic_auth: original_list_tools = mcp._tool_manager.list_tools def list_tools_filtered(): @@ -1525,9 +1557,8 @@ def list_tools_filtered(): # Replace the tool manager's list_tools method mcp._tool_manager.list_tools = list_tools_filtered # type: ignore[method-assign] - logger.info( - "Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)" - ) + mode = "OAuth" if oauth_enabled else "BasicAuth" + logger.info(f"Dynamic tool filtering enabled for {mode} mode") mcp_app = mcp.streamable_http_app() diff --git a/nextcloud_mcp_server/auth/__init__.py b/nextcloud_mcp_server/auth/__init__.py index 3795fe5bd..16ac6e3f3 100644 --- a/nextcloud_mcp_server/auth/__init__.py +++ b/nextcloud_mcp_server/auth/__init__.py @@ -12,6 +12,7 @@ get_required_scopes, has_required_scopes, is_jwt_token, + require_resource_scopes, require_scopes, ) from .unified_verifier import UnifiedTokenVerifier @@ -23,6 +24,7 @@ "ensure_oauth_client", "get_client_from_context", "require_scopes", + "require_resource_scopes", "ScopeAuthorizationError", "InsufficientScopeError", "check_scopes", diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index 1facca17c..b3fa63640 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -28,6 +28,17 @@ logger = logging.getLogger(__name__) +def _validate_redirect_url(url: str, default: str = "/app") -> str: + """Validate redirect URL to prevent open redirects. + + Only relative paths (starting with /) without embedded protocol are allowed. + Rejects absolute URLs, protocol-relative URLs, and other redirect tricks. + """ + if not url.startswith("/") or "://" in url or url.startswith("//"): + return default + return url + + def _should_use_secure_cookies() -> bool: """Determine if cookies should have secure flag. @@ -75,7 +86,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse: logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}") # Get redirect URL from query params (default to /app) - next_url = request.query_params.get("next", "/app") + next_url = _validate_redirect_url(request.query_params.get("next", "/app")) logger.info(f"oauth_login - next_url: {next_url}") # Generate state for CSRF protection @@ -442,7 +453,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo # Create response and set session cookie # Redirect to stored next_url (from OAuth session) or /app as default - response = RedirectResponse(next_url, status_code=302) + response = RedirectResponse(_validate_redirect_url(next_url), status_code=302) response.set_cookie( key="mcp_session", value=user_id, @@ -465,7 +476,9 @@ async def oauth_logout(request: Request) -> RedirectResponse: Returns: 302 redirect with cleared session cookie """ - next_url = request.query_params.get("next", "/oauth/login") + next_url = _validate_redirect_url( + request.query_params.get("next", "/oauth/login"), default="/oauth/login" + ) # TODO: Optionally revoke refresh token from storage # session_id = request.cookies.get("mcp_session") diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index b959a0683..7744b0157 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -475,6 +475,43 @@ async def create_note(): return sorted(all_scopes) +def require_resource_scopes(*required_scopes: str): + """Decorator to require scopes for MCP resource handlers. + + Unlike require_scopes (which extracts Context from tool parameters), + this decorator reads the access token directly from the MCP SDK's + auth_context_var. This works for @mcp.resource() handlers that don't + receive a Context parameter. + + When no access token is present (unconfigured BasicAuth), all access + is permitted for backward compatibility. + + Args: + *required_scopes: Scope strings required for access + + Raises: + InsufficientScopeError: If the token lacks required scopes + """ + + def decorator(func: Callable) -> Callable: + func._required_scopes = list(required_scopes) # type: ignore[attr-defined] + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + access_token: AccessToken | None = get_access_token() + if access_token is None: + # No token → BasicAuth without scope config → allow all + return await func(*args, **kwargs) + missing = set(required_scopes) - set(access_token.scopes or []) + if missing: + raise InsufficientScopeError(sorted(missing)) + return await func(*args, **kwargs) + + return wrapper + + return decorator + + # ── Login Flow v2 helpers ──────────────────────────────────────────────── # Scope cache: user_id → (expires_at, scopes) diff --git a/nextcloud_mcp_server/client/calendar.py b/nextcloud_mcp_server/client/calendar.py index d462bf27e..cb2ed8f49 100644 --- a/nextcloud_mcp_server/client/calendar.py +++ b/nextcloud_mcp_server/client/calendar.py @@ -13,6 +13,8 @@ from icalendar import Alarm, Calendar, vDDDTypes, vRecur from icalendar import Event as ICalEvent from icalendar import Todo as ICalTodo + +from nextcloud_mcp_server.utils.xml import escape_xml from lxml import etree # type: ignore[import-untyped] from ..config import get_nextcloud_ssl_verify @@ -123,7 +125,8 @@ async def list_calendars(self) -> List[Dict[str, Any]]: result = [] # Parse XML response - tree = etree.fromstring(response.raw.encode("utf-8")) + parser = etree.XMLParser(resolve_entities=False, no_network=True) + tree = etree.fromstring(response.raw.encode("utf-8"), parser=parser) ns = { "d": "DAV:", "cs": "http://calendarserver.org/ns/", @@ -203,9 +206,9 @@ async def create_calendar( - {display_name or calendar_name} - {color} - {description} + {escape_xml(display_name or calendar_name)} + {escape_xml(color)} + {escape_xml(description)} diff --git a/nextcloud_mcp_server/client/contacts.py b/nextcloud_mcp_server/client/contacts.py index 8f959f66e..95dd4eec1 100644 --- a/nextcloud_mcp_server/client/contacts.py +++ b/nextcloud_mcp_server/client/contacts.py @@ -1,10 +1,12 @@ """CardDAV client for NextCloud contacts operations.""" import logging -import xml.etree.ElementTree as ET +import defusedxml.ElementTree as ET from pythonvCard4.vcard import Contact +from nextcloud_mcp_server.utils.xml import escape_xml + from .base import BaseNextcloudClient logger = logging.getLogger(__name__) @@ -104,7 +106,7 @@ async def create_addressbook(self, *, name: str, display_name: str): - {display_name} + {escape_xml(display_name)} """ diff --git a/nextcloud_mcp_server/client/cookbook.py b/nextcloud_mcp_server/client/cookbook.py index 1b2926d19..7251899ad 100644 --- a/nextcloud_mcp_server/client/cookbook.py +++ b/nextcloud_mcp_server/client/cookbook.py @@ -6,6 +6,8 @@ from httpx import Timeout +from nextcloud_mcp_server.utils.url import validate_external_url + from .base import BaseNextcloudClient logger = logging.getLogger(__name__) @@ -130,6 +132,7 @@ async def import_recipe(self, url: str) -> Dict[str, Any]: Returns: Full imported recipe data """ + validate_external_url(url) logger.info(f"Importing recipe from URL: {url}") response = await self._make_request( "POST", diff --git a/nextcloud_mcp_server/client/news.py b/nextcloud_mcp_server/client/news.py index 679a88225..17f8d8872 100644 --- a/nextcloud_mcp_server/client/news.py +++ b/nextcloud_mcp_server/client/news.py @@ -4,6 +4,8 @@ from enum import IntEnum from typing import Any +from nextcloud_mcp_server.utils.url import validate_external_url + from .base import BaseNextcloudClient logger = logging.getLogger(__name__) @@ -121,6 +123,7 @@ async def create_feed( Raises: HTTPStatusError: 409 if feed already exists, 422 if URL is invalid """ + validate_external_url(url) body: dict[str, Any] = {"url": url} if folder_id is not None: body["folderId"] = folder_id diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index 2d6356e54..e92ff0e26 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -2,13 +2,16 @@ import logging import mimetypes -import xml.etree.ElementTree as ET +import defusedxml.ElementTree as ET from email.utils import parsedate_to_datetime from typing import Any, Dict, List, Optional, Tuple from urllib.parse import unquote from httpx import HTTPStatusError +from nextcloud_mcp_server.utils.path import sanitize_webdav_path +from nextcloud_mcp_server.utils.xml import escape_xml + from .base import BaseNextcloudClient logger = logging.getLogger(__name__) @@ -27,7 +30,7 @@ async def delete_resource(self, path: str) -> Dict[str, Any]: else: path_with_slash = path - webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path_with_slash)}" logger.debug(f"Deleting WebDAV resource: {webdav_path}") headers = {"OCS-APIRequest": "true"} @@ -220,7 +223,7 @@ async def get_note_attachment( async def list_directory(self, path: str = "") -> List[Dict[str, Any]]: """List files and directories in the specified path via WebDAV PROPFIND.""" - webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path)}" if not webdav_path.endswith("/"): webdav_path += "/" @@ -318,7 +321,7 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]: async def read_file(self, path: str) -> Tuple[bytes, str]: """Read a file's content via WebDAV GET.""" - webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path)}" logger.debug(f"Reading file: {path}") @@ -345,7 +348,7 @@ async def write_file( self, path: str, content: bytes, content_type: Optional[str] = None ) -> Dict[str, Any]: """Write content to a file via WebDAV PUT.""" - webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path)}" logger.debug(f"Writing file: {path}") @@ -376,7 +379,7 @@ async def create_directory( self, path: str, recursive: bool = False ) -> Dict[str, Any]: """Create a directory via WebDAV MKCOL.""" - webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path)}" if not webdav_path.endswith("/"): webdav_path += "/" @@ -433,9 +436,9 @@ async def move_resource( Returns: Dict with status_code and optional message """ - source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}" + source_webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(source_path)}" destination_webdav_path = ( - f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}" + f"{self._get_webdav_base_path()}/{sanitize_webdav_path(destination_path)}" ) # Ensure paths have consistent trailing slashes for directories @@ -514,9 +517,9 @@ async def copy_resource( Returns: Dict with status_code and optional message """ - source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}" + source_webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(source_path)}" destination_webdav_path = ( - f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}" + f"{self._get_webdav_base_path()}/{sanitize_webdav_path(destination_path)}" ) # Ensure paths have consistent trailing slashes for directories @@ -654,7 +657,7 @@ def _build_search_xml( username = self.username scope_path = f"/files/{username}" if scope: - scope_path = f"{scope_path}/{scope.lstrip('/')}" + scope_path = f"{scope_path}/{sanitize_webdav_path(scope)}" # Build property list prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties]) @@ -875,7 +878,7 @@ async def find_by_name( - {pattern} + {escape_xml(pattern)} """ @@ -908,7 +911,7 @@ async def find_by_type( - {mime_type} + {escape_xml(mime_type)} """ @@ -994,7 +997,7 @@ async def find_by_tag( - %{tag_name}% + %{escape_xml(tag_name)}% """ @@ -1308,7 +1311,7 @@ async def get_file_info(self, path: str) -> dict[str, Any] | None: File info dictionary with id, name, size, content_type, etc. Returns None if file not found. """ - webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}" + webdav_path = f"{self._get_webdav_base_path()}/{sanitize_webdav_path(path)}" propfind_body = """ diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 2c5c06855..bb1b719c8 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -252,7 +252,7 @@ class Settings: metrics_enabled: bool = True metrics_port: int = 9090 otel_exporter_otlp_endpoint: str | None = None - otel_exporter_verify_ssl: bool = False + otel_exporter_verify_ssl: bool = True otel_service_name: str = "nextcloud-mcp-server" otel_traces_sampler: str = "always_on" otel_traces_sampler_arg: float = 1.0 @@ -590,7 +590,7 @@ def get_settings() -> Settings: metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true", metrics_port=int(os.getenv("METRICS_PORT", "9090")), otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), - otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower() + otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "true").lower() == "true", otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"), otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"), @@ -602,6 +602,32 @@ def get_settings() -> Settings: ) +def get_basic_auth_scopes(username: str) -> list[str] | None: + """Get configured scopes for a BasicAuth user. + + Reads from BASIC_AUTH_SCOPES_ environment variable. + Username is uppercased and hyphens are replaced with underscores + to form a valid env var name. + + Args: + username: BasicAuth username + + Returns: + List of scope strings, or None if no scope config exists + (None means all operations are allowed for backward compatibility) + + Example: + BASIC_AUTH_SCOPES_CLAUDE=files:read,files:write,notes:read + BASIC_AUTH_SCOPES_N8N=calendar:read,calendar:write + # BASIC_AUTH_SCOPES_ADMIN not set → full access + """ + env_key = f"BASIC_AUTH_SCOPES_{username.upper().replace('-', '_')}" + raw = os.getenv(env_key) + if raw is None: + return None + return [s.strip() for s in raw.split(",") if s.strip()] + + def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext: """Return the SSL verification setting for Nextcloud connections. diff --git a/nextcloud_mcp_server/providers/ollama.py b/nextcloud_mcp_server/providers/ollama.py index 5fd37d045..37ea06651 100644 --- a/nextcloud_mcp_server/providers/ollama.py +++ b/nextcloud_mcp_server/providers/ollama.py @@ -215,7 +215,9 @@ def _check_model_is_loaded(self, model: str, autoload: bool = True): model: Model name to check autoload: Whether to automatically pull the model if not loaded """ - response = httpx.get(f"{self.base_url}/api/tags") + response = httpx.get( + f"{self.base_url}/api/tags", verify=self.verify_ssl + ) response.raise_for_status() models = [m["name"] for m in response.json().get("models", [])] @@ -226,7 +228,11 @@ def _check_model_is_loaded(self, model: str, autoload: bool = True): "Model '%s' not yet available in ollama, attempting to pull now...", model, ) - response = httpx.post(f"{self.base_url}/api/pull", json={"model": model}) + response = httpx.post( + f"{self.base_url}/api/pull", + json={"model": model}, + verify=self.verify_ssl, + ) response.raise_for_status() async def close(self) -> None: diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 79e8e884b..fdad88862 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -5,7 +5,7 @@ from mcp.shared.exceptions import McpError from mcp.types import ErrorData, ToolAnnotations -from nextcloud_mcp_server.auth import require_scopes +from nextcloud_mcp_server.auth import require_resource_scopes, require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.cookbook import ( Category, @@ -31,6 +31,7 @@ def configure_cookbook_tools(mcp: FastMCP): @mcp.resource("cookbook://version") + @require_resource_scopes("cookbook:read") async def cookbook_get_version(): """Get the Cookbook app and API version""" ctx: Context = mcp.get_context() @@ -39,6 +40,7 @@ async def cookbook_get_version(): return Version(**version_data) @mcp.resource("cookbook://config") + @require_resource_scopes("cookbook:read") async def cookbook_get_config(): """Get the Cookbook app configuration""" ctx: Context = mcp.get_context() @@ -47,6 +49,7 @@ async def cookbook_get_config(): return CookbookConfig(**config_data) @mcp.resource("nc://Cookbook/{recipe_id}") + @require_resource_scopes("cookbook:read") async def nc_cookbook_get_recipe_resource(recipe_id: int): """Get a recipe by ID using resource URI""" ctx: Context = mcp.get_context() diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index f48e13456..87c369b6c 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -4,7 +4,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.types import ToolAnnotations -from nextcloud_mcp_server.auth import require_scopes +from nextcloud_mcp_server.auth import require_resource_scopes, require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( CardOperationResponse, @@ -33,6 +33,7 @@ def configure_deck_tools(mcp: FastMCP): # Resources @mcp.resource("nc://Deck/boards") + @require_resource_scopes("deck:read") async def deck_boards_resource(): """List all Nextcloud Deck boards""" ctx: Context = mcp.get_context() @@ -42,6 +43,7 @@ async def deck_boards_resource(): return [board.model_dump() for board in boards] @mcp.resource("nc://Deck/boards/{board_id}") + @require_resource_scopes("deck:read") async def deck_board_resource(board_id: int): """Get details of a specific Nextcloud Deck board""" ctx: Context = mcp.get_context() @@ -53,6 +55,7 @@ async def deck_board_resource(board_id: int): return board.model_dump() @mcp.resource("nc://Deck/boards/{board_id}/stacks") + @require_resource_scopes("deck:read") async def deck_stacks_resource(board_id: int): """List all stacks in a Nextcloud Deck board""" ctx: Context = mcp.get_context() @@ -64,6 +67,7 @@ async def deck_stacks_resource(board_id: int): return [stack.model_dump() for stack in stacks] @mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}") + @require_resource_scopes("deck:read") async def deck_stack_resource(board_id: int, stack_id: int): """Get details of a specific Nextcloud Deck stack""" ctx: Context = mcp.get_context() @@ -75,6 +79,7 @@ async def deck_stack_resource(board_id: int, stack_id: int): return stack.model_dump() @mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards") + @require_resource_scopes("deck:read") async def deck_cards_resource(board_id: int, stack_id: int): """List all cards in a Nextcloud Deck stack""" ctx: Context = mcp.get_context() @@ -88,6 +93,7 @@ async def deck_cards_resource(board_id: int, stack_id: int): return [] @mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}") + @require_resource_scopes("deck:read") async def deck_card_resource(board_id: int, stack_id: int, card_id: int): """Get details of a specific Nextcloud Deck card""" ctx: Context = mcp.get_context() @@ -99,6 +105,7 @@ async def deck_card_resource(board_id: int, stack_id: int, card_id: int): return card.model_dump() @mcp.resource("nc://Deck/boards/{board_id}/labels") + @require_resource_scopes("deck:read") async def deck_labels_resource(board_id: int): """List all labels in a Nextcloud Deck board""" ctx: Context = mcp.get_context() @@ -110,6 +117,7 @@ async def deck_labels_resource(board_id: int): return [label.model_dump() for label in (board.labels or [])] @mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}") + @require_resource_scopes("deck:read") async def deck_label_resource(board_id: int, label_id: int): """Get details of a specific Nextcloud Deck label""" ctx: Context = mcp.get_context() diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 5088fc26d..64f97830f 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -5,7 +5,7 @@ from mcp.shared.exceptions import McpError from mcp.types import ErrorData, ToolAnnotations -from nextcloud_mcp_server.auth import require_scopes +from nextcloud_mcp_server.auth import require_resource_scopes, require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.notes import ( AppendContentResponse, @@ -24,6 +24,7 @@ def configure_notes_tools(mcp: FastMCP): @mcp.resource("notes://settings") + @require_resource_scopes("notes:read") async def notes_get_settings(): """Get the Notes App settings""" ctx: Context = ( @@ -34,6 +35,7 @@ async def notes_get_settings(): return NotesSettings(**settings_data) @mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}") + @require_resource_scopes("notes:read") async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): """Get a specific attachment from a note""" ctx: Context = mcp.get_context() @@ -55,6 +57,7 @@ async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: st } @mcp.resource("nc://Notes/{note_id}") + @require_resource_scopes("notes:read") async def nc_get_note_resource(note_id: int): """Get user note using note id""" diff --git a/nextcloud_mcp_server/server/sharing.py b/nextcloud_mcp_server/server/sharing.py index 207810db6..84c316131 100644 --- a/nextcloud_mcp_server/server/sharing.py +++ b/nextcloud_mcp_server/server/sharing.py @@ -89,7 +89,7 @@ async def nc_share_delete(share_id: int, ctx: Context) -> str: title="Get Share Details", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) - @require_scopes("sharing:write") + @require_scopes("sharing:read") @instrument_tool async def nc_share_get(share_id: int, ctx: Context) -> str: """Get information about a specific share. @@ -111,7 +111,7 @@ async def nc_share_get(share_id: int, ctx: Context) -> str: title="List Shares", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) - @require_scopes("sharing:write") + @require_scopes("sharing:read") @instrument_tool async def nc_share_list( ctx: Context, path: str | None = None, shared_with_me: bool = False diff --git a/nextcloud_mcp_server/utils/path.py b/nextcloud_mcp_server/utils/path.py new file mode 100644 index 000000000..db600282d --- /dev/null +++ b/nextcloud_mcp_server/utils/path.py @@ -0,0 +1,24 @@ +"""Path utility functions for safe WebDAV path handling.""" + +import posixpath + + +def sanitize_webdav_path(path: str) -> str: + """Sanitize a WebDAV path to prevent directory traversal attacks. + + Normalizes the path and rejects any containing '..' components. + Nextcloud blocks traversal server-side, but this provides defense-in-depth. + + Args: + path: User-provided file/directory path. + + Returns: + Normalized path without leading slash. + + Raises: + ValueError: If path contains '..' traversal components. + """ + normalized = posixpath.normpath(path) + if ".." in normalized.split("/"): + raise ValueError(f"Path traversal detected: {path!r}") + return normalized.lstrip("/") diff --git a/nextcloud_mcp_server/utils/url.py b/nextcloud_mcp_server/utils/url.py new file mode 100644 index 000000000..9148d1d54 --- /dev/null +++ b/nextcloud_mcp_server/utils/url.py @@ -0,0 +1,42 @@ +"""URL validation utilities for SSRF protection.""" + +import ipaddress +from urllib.parse import urlparse + + +def validate_external_url(url: str) -> str: + """Validate that a URL points to an external resource. + + Rejects non-HTTP schemes and URLs pointing to private/loopback/link-local + IP addresses to prevent SSRF attacks. Hostname-based URLs that resolve to + private IPs are not caught here (DNS resolution is not performed), but the + schema and IP-literal checks cover the most common SSRF vectors. + + Args: + url: URL to validate. + + Returns: + The URL unchanged if valid. + + Raises: + ValueError: If the URL scheme is not http/https or points to a + private/internal IP address. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"URL scheme must be http or https, got {parsed.scheme!r}" + ) + if parsed.hostname: + try: + ip = ipaddress.ip_address(parsed.hostname) + if ip.is_private or ip.is_loopback or ip.is_link_local: + raise ValueError( + f"URL points to private/internal address: {parsed.hostname}" + ) + except ValueError as e: + # ip_address() raises ValueError for non-IP hostnames — that's fine, + # it means the hostname is a DNS name (not an IP literal) + if "does not appear to be" not in str(e): + raise + return url diff --git a/nextcloud_mcp_server/utils/xml.py b/nextcloud_mcp_server/utils/xml.py new file mode 100644 index 000000000..467e5ccef --- /dev/null +++ b/nextcloud_mcp_server/utils/xml.py @@ -0,0 +1,12 @@ +"""XML utility functions for safe content handling.""" + +from xml.sax.saxutils import escape as _xml_escape + + +def escape_xml(value: str) -> str: + """Escape XML special characters in user-provided values. + + Prevents XML injection when interpolating values into XML documents. + Escapes &, <, >, ', and " characters. + """ + return _xml_escape(value, entities={"'": "'", '"': """}) diff --git a/pyproject.toml b/pyproject.toml index a74dd074e..2e04d6698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "opentelemetry-instrumentation-logging>=0.49b2", # Logging integration "opentelemetry-exporter-otlp-proto-grpc>=1.28.2", # OTLP gRPC exporter "python-json-logger>=3.2.0", # Structured JSON logging + "defusedxml>=0.7.1", # Safe XML parsing (XXE protection) "jinja2>=3.1.6", "langchain-text-splitters>=1.0.0", "markdownify>=0.14.1", # HTML to Markdown conversion for News items diff --git a/uv.lock b/uv.lock index d61abba2a..8fc9c206e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", @@ -704,6 +704,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1999,6 +2008,7 @@ dependencies = [ { name = "boto3" }, { name = "caldav" }, { name = "click" }, + { name = "defusedxml" }, { name = "fastembed" }, { name = "httpx" }, { name = "icalendar" }, @@ -2050,6 +2060,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.35.0" }, { name = "caldav", git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx" }, { name = "click", specifier = ">=8.1.8" }, + { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastembed", specifier = ">=0.7.3" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=7.0.2,<7.1.0" },