From 47a1ca7ab1cb3198bdf31088ec703e5915423d03 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Apr 2026 20:23:28 +0800 Subject: [PATCH] security(mcp): refuse standalone MCP binding on 0.0.0.0 / :: / * The standalone `cmd/mcp` binary currently binds with `fmt.Sprintf(":%d", Addr)`, which on Linux and Docker is equivalent to listening on every interface (0.0.0.0 + ::). The MCP endpoint exposes code-generation, DB-execution and menu/API mutation tools; exposing it to any routable network effectively hands out RCE to whoever can reach the port. This patch makes the safe default explicit: - `config.MCP.ListenHost`: new optional field. Empty defaults to "127.0.0.1". - `cmd/mcp/config.go`: `applyStandaloneDefaults` fills the loopback default so an operator who forgets to configure the field never accidentally opens the port to the world. - `cmd/mcp/main.go`: refuses to start when `listen_host` resolves to `0.0.0.0`, `::` or `*`, with a panic message pointing to the safer fix (loopback or a specific private interface + reverse proxy). - `cmd/mcp/config.yaml`: documents the new field with the recommended 127.0.0.1 default. Startup bind address is now built via `net.JoinHostPort`, which also makes IPv6 literal hosts (e.g. `[::1]`) work correctly. No functional change when the existing `base_url: http://127.0.0.1:8889/mcp` convention is followed. The only behavioral regression is for deployments that actively relied on global-interface exposure, which this patch argues should never have been the default. Made-with: Cursor --- server/cmd/mcp/config.go | 5 +++++ server/cmd/mcp/config.yaml | 6 ++++++ server/cmd/mcp/main.go | 31 ++++++++++++++++++++++++++++++- server/config/mcp.go | 8 ++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/server/cmd/mcp/config.go b/server/cmd/mcp/config.go index cc1d906c46..ebd04a7bb1 100644 --- a/server/cmd/mcp/config.go +++ b/server/cmd/mcp/config.go @@ -109,6 +109,11 @@ func applyStandaloneDefaults(configPath string, cfg *standaloneConfig) { if cfg.MCP.Addr == 0 { cfg.MCP.Addr = 8889 } + if strings.TrimSpace(cfg.MCP.ListenHost) == "" { + // Default to loopback so an operator who forgets to set mcp.listen_host + // never accidentally exposes MCP tools to the public network. + cfg.MCP.ListenHost = "127.0.0.1" + } if cfg.MCP.AuthHeader == "" { cfg.MCP.AuthHeader = "x-token" } diff --git a/server/cmd/mcp/config.yaml b/server/cmd/mcp/config.yaml index 84fd08217b..7019654867 100644 --- a/server/cmd/mcp/config.yaml +++ b/server/cmd/mcp/config.yaml @@ -3,6 +3,12 @@ mcp: version: v1.0.0 path: /mcp addr: 8889 + # listen_host is the interface the standalone MCP binary binds to. + # Leave empty or set to 127.0.0.1 for loopback-only access (recommended). + # 0.0.0.0 / :: / * are refused at startup because MCP exposes code-gen + # and DB-execution tools. Set a specific private IP if remote access is + # required, and always put a reverse proxy in front. + listen_host: 127.0.0.1 base_url: http://127.0.0.1:8889/mcp upstream_base_url: http://127.0.0.1:8888 auth_header: x-token diff --git a/server/cmd/mcp/main.go b/server/cmd/mcp/main.go index ef7c353959..35845404a3 100644 --- a/server/cmd/mcp/main.go +++ b/server/cmd/mcp/main.go @@ -2,6 +2,9 @@ package main import ( "fmt" + "net" + "strconv" + "strings" "github.com/flipped-aurora/gin-vue-admin/server/global" mcpTool "github.com/flipped-aurora/gin-vue-admin/server/mcp" @@ -9,6 +12,20 @@ import ( "go.uber.org/zap" ) +// unsafeListenHosts is the set of MCP bind hosts that the standalone MCP +// binary refuses to start on. The MCP endpoint exposes code-generation, +// database execution and route mutation tools, so binding to any public +// interface effectively grants RCE to anyone who can reach the port. +// +// Operators that deliberately want MCP reachable from outside localhost +// should pick a specific private IP (e.g. the Docker bridge or a VPN +// interface) rather than blanket 0.0.0.0 / ::. +var unsafeListenHosts = map[string]struct{}{ + "0.0.0.0": {}, + "::": {}, + "*": {}, +} + func main() { configPath, err := loadStandaloneConfig() if err != nil { @@ -19,12 +36,24 @@ func main() { panic(err) } - addr := fmt.Sprintf(":%d", global.GVA_CONFIG.MCP.Addr) + host := strings.TrimSpace(global.GVA_CONFIG.MCP.ListenHost) + if _, bad := unsafeListenHosts[host]; bad { + panic(fmt.Errorf( + "mcp.listen_host=%q is refused: binding MCP to any public interface exposes "+ + "code-generation and DB-execution tools. Set mcp.listen_host to 127.0.0.1 "+ + "(loopback) or a specific private interface, and front it with a reverse proxy "+ + "if remote access is required.", + host, + )) + } + + addr := net.JoinHostPort(host, strconv.Itoa(global.GVA_CONFIG.MCP.Addr)) server := mcpTool.NewStreamableHTTPServer() global.GVA_LOG.Info("mcp独立服务启动", zap.String("config", configPath), zap.String("addr", addr), + zap.String("listen_host", host), zap.String("path", global.GVA_CONFIG.MCP.Path), zap.String("upstream", global.GVA_CONFIG.MCP.UpstreamBaseURL), ) diff --git a/server/config/mcp.go b/server/config/mcp.go index 87028f36fd..1cef7e4455 100644 --- a/server/config/mcp.go +++ b/server/config/mcp.go @@ -10,6 +10,14 @@ type MCP struct { AuthHeader string `mapstructure:"auth_header" json:"auth_header" yaml:"auth_header"` RequestTimeout int `mapstructure:"request_timeout" json:"request_timeout" yaml:"request_timeout"` + // ListenHost is the interface the standalone MCP binary binds to. + // Leave empty to default to "127.0.0.1" (loopback only). + // Binding to "0.0.0.0" / "::" / "*" is explicitly refused because the MCP + // endpoint exposes code-generation / DB-execution tools that must not be + // reachable from the public network. Put MCP behind a reverse proxy or + // bind to a specific private interface instead. + ListenHost string `mapstructure:"listen_host" json:"listen_host" yaml:"listen_host"` + // Deprecated fields kept for backward compatibility with older configs. SSEPath string `mapstructure:"sse_path" json:"sse_path" yaml:"sse_path"` MessagePath string `mapstructure:"message_path" json:"message_path" yaml:"message_path"`