Files
goclaw/internal/mcp/validation_allowlist_test.go
Zezae Oh c31dd21934 feat(mcp): opt-in allowlist for trusted private MCP hosts (#1248)
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.
2026-06-21 09:37:05 +07:00

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")
}
}