Merge pull request #2772 from yueguobin/feature/mcp-transport-security-config

feat: add configurable MCP transport security settings via gns3_server.conf
This commit is contained in:
Jeremy Grossmann 2026-06-05 19:05:23 +02:00 committed by GitHub
commit 13d1563762
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 136 additions and 5 deletions

View File

@ -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=<jwt>
# 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=<jwt>
```
### Source Files

View File

@ -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 ─────────────────────────────────────────────────────

View File

@ -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
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:*

View File

@ -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):