mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 20:13:25 +00:00
Local pkg-helper installs an arm64 mcp-server binary on macOS hosts; the existing allowlist (deno, bun, uvx, ...) didn't cover it so MCP server config validation rejected legitimate entries.
479 lines
14 KiB
Go
479 lines
14 KiB
Go
package mcp
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
)
|
|
|
|
// Allowed commands for stdio transport (basename only).
|
|
// This is a restrictive allowlist — only well-known runtimes are permitted.
|
|
var allowedCommands = map[string]bool{
|
|
"node": true, "npx": true, "npm": true,
|
|
"python": true, "python3": true, "python2": true,
|
|
"ruby": true, "go": true, "cargo": true,
|
|
"java": true, "dotnet": true, "php": true,
|
|
"uvx": true, "uv": true, "pipx": true,
|
|
"deno": true, "bun": true,
|
|
"mcp-server-darwin-arm64": true,
|
|
}
|
|
|
|
// Shell metacharacters that indicate injection attempt.
|
|
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
|
|
}
|
|
|
|
// Fail-closed env var allowlist — only these are permitted for env: resolution.
|
|
var allowedEnvVars = map[string]bool{
|
|
"HOME": true, "USER": true, "PATH": true,
|
|
"SHELL": true, "LANG": true, "LC_ALL": true,
|
|
"TZ": true, "TERM": true,
|
|
"NODE_ENV": true, "ENVIRONMENT": true,
|
|
"LOG_LEVEL": true, "DEBUG": true,
|
|
}
|
|
|
|
// ValidateCommand checks stdio command for injection vulnerabilities.
|
|
// Returns nil if the command is safe, or an error describing the issue.
|
|
func ValidateCommand(cmd string) error {
|
|
if cmd == "" {
|
|
return nil // Empty is valid (not stdio)
|
|
}
|
|
|
|
// Trim whitespace and check for empty
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd == "" {
|
|
return fmt.Errorf("command is empty or whitespace only")
|
|
}
|
|
|
|
// Check for shell metacharacters
|
|
if shellMetaChars.MatchString(cmd) {
|
|
return fmt.Errorf("command contains shell metacharacters")
|
|
}
|
|
|
|
// Check for path traversal
|
|
if strings.Contains(cmd, "..") {
|
|
return fmt.Errorf("command contains path traversal")
|
|
}
|
|
|
|
// Check for newline injection
|
|
if strings.ContainsAny(cmd, "\n\r") {
|
|
return fmt.Errorf("command contains newline characters")
|
|
}
|
|
|
|
basename := commandBasename(cmd)
|
|
|
|
// Only bare runtime names are accepted. Path-bearing commands can point at
|
|
// workspace-controlled wrappers named after an allowlisted runtime.
|
|
if strings.ContainsAny(cmd, `/\`) {
|
|
return fmt.Errorf("command must be a bare allowlisted runtime name, not a path")
|
|
}
|
|
|
|
// Bare command must be in allowlist
|
|
if !allowedCommands[basename] {
|
|
return fmt.Errorf("command %q not in allowlist (allowed: %s)", basename, allowedCommandNames())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateArgs checks command arguments for dangerous patterns.
|
|
func ValidateArgs(args []string) error {
|
|
for i, arg := range args {
|
|
argLower := strings.ToLower(arg)
|
|
for _, pattern := range dangerousArgPatterns {
|
|
if strings.Contains(argLower, pattern) {
|
|
return fmt.Errorf("arg[%d] contains dangerous pattern %q", i, pattern)
|
|
}
|
|
}
|
|
// Check for shell metacharacters in args
|
|
if shellMetaChars.MatchString(arg) {
|
|
return fmt.Errorf("arg[%d] contains shell metacharacters", i)
|
|
}
|
|
}
|
|
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 allowedCommandNames() string {
|
|
names := make([]string, 0, len(allowedCommands))
|
|
for name := range allowedCommands {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
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 != ""
|
|
}
|
|
}
|
|
|
|
// mcpAllowedHostsStore holds the operator-configured allowlist of MCP server
|
|
// hostnames that are exempt from the private-IP SSRF block during MCP config
|
|
// validation (see SetAllowedHosts).
|
|
var mcpAllowedHostsStore atomic.Pointer[map[string]bool]
|
|
|
|
// SetAllowedHosts configures the MCP server hostname allowlist consulted by
|
|
// ValidateURL / ValidateServerConfig. Hostnames are matched
|
|
// case-insensitively against the pre-resolution URL host. Call once at gateway
|
|
// startup; a nil/empty slice disables the allowlist (the default, no behavior
|
|
// change). Only owner/admin gateway config should feed this.
|
|
func SetAllowedHosts(hosts []string) {
|
|
m := make(map[string]bool, len(hosts))
|
|
for _, h := range hosts {
|
|
if h = strings.ToLower(strings.TrimSpace(h)); h != "" {
|
|
m[h] = true
|
|
}
|
|
}
|
|
mcpAllowedHostsStore.Store(&m)
|
|
}
|
|
|
|
// allowedHosts returns the current MCP hostname allowlist, or nil if unset.
|
|
func allowedHosts() map[string]bool {
|
|
if p := mcpAllowedHostsStore.Load(); p != nil {
|
|
return *p
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateURL checks URL for SSRF vulnerabilities using the existing security package.
|
|
// This provides DNS rebinding protection via IP pinning.
|
|
func ValidateURL(rawURL string) error {
|
|
if rawURL == "" {
|
|
return nil
|
|
}
|
|
|
|
// Reuse existing SSRF validation; an operator may allowlist trusted internal
|
|
// MCP hosts (private network self-host) via SetAllowedHosts.
|
|
_, _, err := security.ValidateAllowingHosts(rawURL, allowedHosts())
|
|
if err != nil {
|
|
return fmt.Errorf("URL validation failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateAndResolveEnvVar checks and resolves env: prefix values.
|
|
// Uses FAIL-CLOSED approach: only allowlisted vars are permitted.
|
|
// Returns the resolved value or an error if the var is not allowed.
|
|
func ValidateAndResolveEnvVar(value string) (string, error) {
|
|
after, ok := strings.CutPrefix(value, "env:")
|
|
if !ok {
|
|
return value, nil // Not an env var reference
|
|
}
|
|
|
|
varName := strings.ToUpper(after)
|
|
|
|
// FAIL-CLOSED: only allowlisted vars permitted
|
|
if !allowedEnvVars[varName] {
|
|
return "", fmt.Errorf("env var %q not in allowlist (allowed: HOME, USER, PATH, SHELL, LANG, LC_ALL, TZ, TERM, NODE_ENV, ENVIRONMENT, LOG_LEVEL, DEBUG)", after)
|
|
}
|
|
|
|
return os.Getenv(after), nil
|
|
}
|
|
|
|
// ValidateServerConfig performs all validations for an MCP server configuration.
|
|
// This is a convenience function that validates command+args (for stdio) or URL (for HTTP transports).
|
|
func ValidateServerConfig(transport, command string, args []string, url string) error {
|
|
if transport == "stdio" {
|
|
if err := ValidateCommand(command); err != nil {
|
|
return fmt.Errorf("invalid command: %w", err)
|
|
}
|
|
if err := ValidateArgsForCommand(command, args); err != nil {
|
|
return fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
}
|
|
|
|
if transport == "sse" || transport == "streamable-http" {
|
|
if err := ValidateURL(url); err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateHeaders validates header values for env: references.
|
|
// Returns an error if any header uses a non-allowlisted env var.
|
|
func ValidateHeaders(headers map[string]string) error {
|
|
for k, v := range headers {
|
|
if _, err := ValidateAndResolveEnvVar(v); err != nil {
|
|
return fmt.Errorf("header %q: %w", k, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|