mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-01 06:50:13 +03:00
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:
commit
13d1563762
@ -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
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@ -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:*
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user