From bb39238f0266e9e13d966d3d85ffcb973891beb0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 5 Jun 2026 23:06:06 +0800 Subject: [PATCH 1/5] feat: add configurable MCP transport security settings via gns3_server.conf Add MCP transport security configuration to gns3_server.conf with permissive defaults that align with GNS3's design philosophy and VM distribution requirements. ## Changes ### 1. Configuration Schema (gns3server/schemas/config.py) - Added MCP transport security fields to ServerSettings class: - mcp_enable_dns_rebinding_protection (bool, default: True) - mcp_allowed_hosts (list[str], default: ["*"]) - mcp_allowed_origins (list[str], default: ["*"]) - Added field validators to handle comma-separated string input ### 2. MCP Server Initialization (gns3server/api/routes/mcp/__init__.py) - Import TransportSecuritySettings from mcp.server.transport_security - Added _create_mcp_server() function to read configuration - Updated FastMCP instantiation to use configured security settings ### 3. Configuration Sample (gns3server/config_samples/gns3_server.conf) - Added MCP transport security settings section - Documented default behavior and security options - Provided examples for different use cases ## Design Philosophy **Default: Allow All Hosts** (matches GNS3's 0.0.0.0 binding): - VM distribution works out-of-the-box - Users can access from any network location - Security-conscious users can restrict when needed **Security: Optional Restriction**: Users can configure specific hosts for enhanced security: ``ini mcp_allowed_hosts = 127.0.0.1:*,localhost:*,192.168.1.3:* mcp_allowed_origins = http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:* ``` ## Benefits - Flexible: Users can configure based on security requirements - User-friendly: Default matches GNS3's 0.0.0.0 binding philosophy - Maintainable: No code changes needed for different deployment scenarios - Secure: DNS rebinding protection remains enabled with configurable hosts ## Related - Issue #2771 - FastMCP DNS rebinding protection design - Existing skills configuration in ServerSettings --- gns3server/api/routes/mcp/__init__.py | 18 +++++++++++++++++- gns3server/config_samples/gns3_server.conf | 15 ++++++++++++++- gns3server/schemas/config.py | 20 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 0190452a4..b3752dd70 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -39,6 +39,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 @@ -101,7 +102,22 @@ def _server_url() -> str: # ── 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 + + mcp = FastMCP( + "GNS3 MCP Server", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=cfg.mcp_enable_dns_rebinding_protection, + allowed_hosts=cfg.mcp_allowed_hosts, + allowed_origins=cfg.mcp_allowed_origins, + ), + ) + 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..36588378a 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -183,4 +183,17 @@ 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 +; Enable DNS rebinding protection for MCP server +mcp_enable_dns_rebinding_protection = True + +; Allowed hosts for MCP connections (comma-separated, use * for wildcard) +; Default: * (allow all hosts - recommended for GNS3 VM distribution) +; For enhanced security, restrict to specific hosts: 127.0.0.1:*,localhost:*,192.168.1.3:* +mcp_allowed_hosts = * + +; Allowed origins for MCP connections (comma-separated) +; Default: * (allow all origins) +; For enhanced security: http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:* +mcp_allowed_origins = * diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index be66b2853..bb32e48fd 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -162,8 +162,28 @@ 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 + mcp_enable_dns_rebinding_protection: bool = True + mcp_allowed_hosts: list[str] = Field(default=["*"], description="Allowed Host header values for MCP server") + mcp_allowed_origins: list[str] = Field(default=["*"], description="Allowed Origin header values for MCP server") + 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): From 0e6db9a7b6e0d7e1e11fc2aef0028a66cada559d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 5 Jun 2026 23:20:57 +0800 Subject: [PATCH 2/5] fix: correct MCP transport security config to actually allow all hosts by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP library's TransportSecurityMiddleware only supports exact host matches or "host:*" port wildcards. It does NOT support a standalone "*" wildcard to mean "allow all hosts" — setting allowed_hosts=["*"] would reject every connection because no Host header equals "*". Worse, when transport_security=None was passed to FastMCP while its default host is "127.0.0.1", FastMCP would auto-enable protection with strict localhost-only rules, overriding GNS3's intent to allow all hosts. Root cause analysis: - FastMCP auto-enables DNS rebinding protection when host is localhost and no explicit TransportSecuritySettings is provided - GNS3 was passing transport_security=None (indirectly via FastMCP's default) when protection was disabled, triggering the auto-enable - The TransportSecuritySettings "allowed_hosts" list does NOT support "*" as a catch-all wildcard This fix: 1. Always pass an explicit TransportSecuritySettings to FastMCP - Disabled: TransportSecuritySettings(enable_dns_rebinding_protection=False) - Enabled: TransportSecuritySettings(enable_dns_rebinding_protection=True, ...) 2. Restore mcp_allowed_hosts and mcp_allowed_origins config fields 3. Set mcp_enable_dns_rebinding_protection default to False (allow all hosts) Behaviour: - Default (no config change): all hosts can connect to MCP server - With mcp_enable_dns_rebinding_protection=true: only configured hosts - Aligns with GNS3 server's 0.0.0.0 binding policy --- gns3server/api/routes/mcp/__init__.py | 22 ++++++++++++++-------- gns3server/config_samples/gns3_server.conf | 19 +++++++------------ gns3server/schemas/config.py | 10 +++++++--- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index b3752dd70..00a2770fb 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -106,14 +106,20 @@ def _create_mcp_server() -> FastMCP: """Create MCP server with security settings from configuration.""" cfg = Config.instance().settings.Server - mcp = FastMCP( - "GNS3 MCP Server", - transport_security=TransportSecuritySettings( - enable_dns_rebinding_protection=cfg.mcp_enable_dns_rebinding_protection, - allowed_hosts=cfg.mcp_allowed_hosts, - allowed_origins=cfg.mcp_allowed_origins, - ), - ) + # 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 diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 36588378a..248e18fcc 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -185,15 +185,10 @@ cpus = 1.0 ; Process limit per container pids_limit = 1000 ; MCP (Model Context Protocol) transport security settings -; Enable DNS rebinding protection for MCP server -mcp_enable_dns_rebinding_protection = True - -; Allowed hosts for MCP connections (comma-separated, use * for wildcard) -; Default: * (allow all hosts - recommended for GNS3 VM distribution) -; For enhanced security, restrict to specific hosts: 127.0.0.1:*,localhost:*,192.168.1.3:* -mcp_allowed_hosts = * - -; Allowed origins for MCP connections (comma-separated) -; Default: * (allow all origins) -; For enhanced security: http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:* -mcp_allowed_origins = * +; 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 bb32e48fd..9e328b2a9 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -164,9 +164,13 @@ class ServerSettings(BaseModel): skills_auto_update: bool = True # MCP (Model Context Protocol) transport security settings - mcp_enable_dns_rebinding_protection: bool = True - mcp_allowed_hosts: list[str] = Field(default=["*"], description="Allowed Host header values for MCP server") - mcp_allowed_origins: list[str] = Field(default=["*"], description="Allowed Origin header values for MCP server") + # 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) From 4d87b57f188b2850ef85a4481862fc2f3661b459 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 5 Jun 2026 23:40:01 +0800 Subject: [PATCH 3/5] docs: add MCP transport security documentation Added Transport Security section to MCP service documentation covering: - Default behaviour (disabled, allow all hosts) - How to enable protection via gns3_server.conf - Protection mechanism (Host header validation) - DNS rebinding attack prevention explanation - Behaviour summary table --- docs/features/mcp-service.md | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index 96ec1f10c..d7ed5c63a 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 From db9772ca7a60e2f235a25cf4f18e19dbe196f28b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 6 Jun 2026 00:48:22 +0800 Subject: [PATCH 4/5] fix: resolve MCP server URL host via default route IP when bound to 0.0.0.0 When GNS3 server is configured to listen on 0.0.0.0 (all interfaces), _server_url() was hardcoding 127.0.0.1, making the WebSocket console URL unreachable from remote MCP clients. Use the UDP connect trick (connect to 8.8.8.8:80 without sending data) to discover the default route interface IP, which is the address remote clients can actually reach. --- gns3server/api/routes/mcp/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 00a2770fb..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 @@ -94,8 +95,14 @@ 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}" From 1dcbd9199f4b89cbe52815f5ed52da73fe043a8a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 6 Jun 2026 00:52:21 +0800 Subject: [PATCH 5/5] docs: document MCP server URL host resolution behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation explaining how _server_url() resolves the host when Server.host is 0.0.0.0 or :: — using the default route interface IP instead of hardcoding 127.0.0.1. --- docs/features/mcp-service.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index d7ed5c63a..524750fa9 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -231,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