mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
Registering a remote MCP server (sse/streamable-http) whose hostname resolves to a private IP is rejected at config-validation time by the SSRF guard, with no production escape hatch -- the only bypass is a test-only loopback flag. Self-hosted MCP servers on a private network are therefore unregisterable, even though the runtime MCP client connects to them fine (Test Connection succeeds and lists tools; only create/update input validation blocks). Add an opt-in, operator-configured allowlist (GOCLAW_MCP_ALLOWED_HOSTS, empty by default) of trusted hostnames exempt from the private/loopback IP block during MCP server URL validation only: - security.ValidateAllowingHosts(url, allowedHosts): like Validate but skips the private/loopback block for allowlisted hostnames. The cloud-metadata/link-local (169.254/fe80), multicast and unspecified ranges are never exempted, even for allowlisted hosts. - mcp.SetAllowedHosts wires the operator allowlist into ValidateURL / ValidateServerConfig; default empty => no behavior change. - web_fetch / webhook / redirect SSRF paths are unchanged (those stay agent-influenced and fully guarded). Matching is case-insensitive on the pre-resolution hostname.
58 lines
1.9 KiB
Go
58 lines
1.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
)
|
|
|
|
// The package init() enables the loopback/private bypass for most validation
|
|
// tests; the SSRF allowlist tests below disable it so the private-IP block is
|
|
// actually exercised, then restore it.
|
|
|
|
func TestSetAllowedHosts_ExemptsPrivateIPForMCP(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(false)
|
|
defer security.SetAllowLoopbackForTest(true)
|
|
t.Cleanup(func() { SetAllowedHosts(nil) })
|
|
|
|
SetAllowedHosts([]string{"10.1.2.3"})
|
|
if err := ValidateURL("http://10.1.2.3/mcp"); err != nil {
|
|
t.Fatalf("allowlisted private MCP host should validate, got %v", err)
|
|
}
|
|
if err := ValidateServerConfig("streamable-http", "", nil, "http://10.1.2.3/mcp"); err != nil {
|
|
t.Fatalf("allowlisted private MCP host should pass ValidateServerConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetAllowedHosts_DefaultStillBlocksPrivate(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(false)
|
|
defer security.SetAllowLoopbackForTest(true)
|
|
SetAllowedHosts(nil)
|
|
|
|
if err := ValidateURL("http://10.1.2.3/mcp"); err == nil {
|
|
t.Fatal("private MCP host must be rejected when allowlist is empty")
|
|
}
|
|
}
|
|
|
|
func TestSetAllowedHosts_NeverExemptsMetadata(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(false)
|
|
defer security.SetAllowLoopbackForTest(true)
|
|
t.Cleanup(func() { SetAllowedHosts(nil) })
|
|
|
|
SetAllowedHosts([]string{"169.254.169.254"})
|
|
if err := ValidateURL("http://169.254.169.254/latest/meta-data"); err == nil {
|
|
t.Fatal("metadata endpoint must stay blocked even if allowlisted")
|
|
}
|
|
}
|
|
|
|
func TestSetAllowedHosts_NormalizesEntries(t *testing.T) {
|
|
t.Cleanup(func() { SetAllowedHosts(nil) })
|
|
SetAllowedHosts([]string{" MCP.Internal.Example ", ""})
|
|
if !allowedHosts()["mcp.internal.example"] {
|
|
t.Fatal("expected entry to be trimmed and lowercased")
|
|
}
|
|
if _, ok := allowedHosts()[""]; ok {
|
|
t.Fatal("empty entry must be dropped")
|
|
}
|
|
}
|