diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index 96ec1f10c..524750fa9 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -134,6 +134,62 @@ Add to `claude_desktop_config.json`: } ``` +## Transport Security + +MCP server uses FastMCP's DNS rebinding protection to prevent attackers from +exploiting DNS resolution to access the MCP endpoint through unauthorized domains. + +### Default Behaviour + +DNS rebinding protection is **disabled by default**, allowing connections from +any host. This aligns with GNS3 server's default `host = 0.0.0.0` binding policy, +which is designed for VM distribution scenarios where users access the server +from various network locations. + +### Enabling Protection + +Add to `gns3_server.conf` under the `[Server]` section: + +```ini +; Enable DNS rebinding protection for MCP server +mcp_enable_dns_rebinding_protection = True + +; Allowed hosts (comma-separated, "host:*" port wildcard patterns only) +mcp_allowed_hosts = 127.0.0.1:*,localhost:*,192.168.1.3:* + +; Allowed origins (comma-separated) +mcp_allowed_origins = http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:* +``` + +> **Note**: The MCP library only supports `"host:*"` port wildcard patterns +> (e.g., `"192.168.1.3:*"`). Standalone `"*"` wildcards are not supported. + +### Protection Mechanism + +When protection is enabled, the MCP server validates the `Host` header of +incoming SSE connection requests: + +```python +# Verify the request's Host header matches allowed patterns +validate_request → check Host header → 421 Misdirected Request if invalid +``` + +This prevents DNS rebinding attacks: +1. Attacker registers `evil.com` pointing to your server's IP +2. User's browser makes requests to `evil.com:3080` +3. MCP server checks Host header = `"evil.com:3080"` +4. `"evil.com:3080"` is not in `allowed_hosts` → connection rejected + +### Behaviour Summary + +| `mcp_enable_dns_rebinding_protection` | Result | +|:---|:---| +| `False` (default) | All hosts allowed | +| `True` + correct hosts configured | Only configured hosts allowed | +| `True` + missing/wrong hosts | Connections rejected with 421 | + +For public-facing MCP servers, set `allowed_hosts` to your server's domain name. + ## Architecture ```mermaid @@ -175,10 +231,24 @@ sequenceDiagram The `get_node_console_info` tool returns a WebSocket URL for connecting to a node's console. This endpoint is protocol-agnostic — it works for **telnet**, **ssh**, and **vnc** console types alike. The WebSocket simply proxies raw byte streams between the client and the compute node; protocol negotiation (e.g. SSH key exchange) happens on the compute side. +The WebSocket URL is constructed using the server's `_server_url()`, which resolves the host as follows: + +| `Server.host` value | Resolved host in URL | +|:---|:---| +| Specific IP or hostname (e.g. `192.168.1.3`) | Used as-is | +| `0.0.0.0` (IPv4 any, default) | Detected via **default route interface IP** | +| `::` (IPv6 any) | Detected via default route interface IP | +| Detection failure | Fallback to `127.0.0.1` | + +When `Server.host` is `0.0.0.0` (listen on all interfaces), the MCP server discovers the default route interface IP using a UDP socket connect to `8.8.8.8:80` — no network data is sent, the operating system simply selects the interface that would be used for the default route. This ensures the returned WebSocket URL uses a reachable address (e.g. `192.168.1.3` instead of `127.0.0.1`). + +If the configured host is already a specific IP or hostname (not `0.0.0.0`), it is used directly in the URL without modification. + Use `websocat` to connect from the command line: ```bash -websocat wss://host:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token= +# The host in the URL is automatically resolved to a reachable address +websocat ws://192.168.1.3:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token= ``` ### Source Files diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 0190452a4..8cf8d0bcb 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -30,6 +30,7 @@ import contextvars import json import asyncio import logging +import socket from typing import Any, Annotated from urllib.parse import parse_qs @@ -39,6 +40,7 @@ from fastapi.responses import Response from pydantic import Field from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings from gns3server.config import Config from gns3server.services import auth_service @@ -93,15 +95,42 @@ async def _validate_token(token: str) -> bool: def _server_url() -> str: cfg = Config.instance().settings host = cfg.Server.host - if host == "0.0.0.0": - host = "127.0.0.1" + if host in ("0.0.0.0", "::"): + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.settimeout(0.1) + s.connect(("8.8.8.8", 80)) + host = s.getsockname()[0] + except OSError: + host = "127.0.0.1" scheme = "https" if cfg.Server.enable_ssl else "http" return f"{scheme}://{host}:{cfg.Server.port}" # ── FastMCP Server ──────────────────────────────────────────────────── -mcp = FastMCP("GNS3 MCP Server") +def _create_mcp_server() -> FastMCP: + """Create MCP server with security settings from configuration.""" + cfg = Config.instance().settings.Server + + # Always pass an explicit TransportSecuritySettings to prevent FastMCP + # from auto-enabling protection when host is localhost (its default). + if cfg.mcp_enable_dns_rebinding_protection: + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=cfg.mcp_allowed_hosts or ["127.0.0.1:*", "localhost:*"], + allowed_origins=cfg.mcp_allowed_origins or ["http://127.0.0.1:*", "http://localhost:*"], + ) + else: + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ) + + mcp = FastMCP("GNS3 MCP Server", transport_security=transport_security) + return mcp + + +mcp = _create_mcp_server() # ── Tool handlers ───────────────────────────────────────────────────── diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index c19a60077..248e18fcc 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -183,4 +183,12 @@ memory = 2g ; CPU cores per container (e.g., 1.0, 2.0) cpus = 1.0 ; Process limit per container -pids_limit = 1000 \ No newline at end of file +pids_limit = 1000 +; MCP (Model Context Protocol) transport security settings +; Disabled by default — allows connections from any host (matches GNS3 +; server's 0.0.0.0 binding). Enable and configure allowed hosts below +; for enhanced security against DNS rebinding attacks. +; Note: Only "host:*" port wildcards are supported (e.g., "127.0.0.1:*"). +;mcp_enable_dns_rebinding_protection = true +;mcp_allowed_hosts = 127.0.0.1:*,localhost:* +;mcp_allowed_origins = http://127.0.0.1:*,http://localhost:* diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index be66b2853..9e328b2a9 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -162,8 +162,32 @@ class ServerSettings(BaseModel): skills_repo_url: str = "https://github.com/gns3/gns3-skills.git" skills_repo_branch: str = "main" skills_auto_update: bool = True + + # MCP (Model Context Protocol) transport security settings + # DNS rebinding protection is disabled by default to allow connections + # from any host (aligns with GNS3 server's 0.0.0.0 binding). + # Users with security requirements can enable protection and specify + # allowed hosts using "host:*" port wildcard patterns. + mcp_enable_dns_rebinding_protection: bool = False + mcp_allowed_hosts: list[str] = Field(default_factory=list) + mcp_allowed_origins: list[str] = Field(default_factory=list) + model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) + @field_validator("mcp_allowed_hosts", mode="before") + @classmethod + def split_mcp_allowed_hosts(cls, v): + if v and isinstance(v, str): + return v.split(",") + return list() + + @field_validator("mcp_allowed_origins", mode="before") + @classmethod + def split_mcp_allowed_origins(cls, v): + if v and isinstance(v, str): + return v.split(",") + return list() + @field_validator("additional_images_paths", mode="before") @classmethod def split_additional_images_paths(cls, v):