mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 06:15:25 +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.
48 lines
1.8 KiB
Go
48 lines
1.8 KiB
Go
package security
|
|
|
|
import "testing"
|
|
|
|
// ---- ValidateAllowingHosts (operator MCP host allowlist) ----
|
|
|
|
func TestValidateAllowingHosts_AllowsAllowlistedPrivateIP(t *testing.T) {
|
|
allowed := map[string]bool{"10.1.2.3": true}
|
|
if _, _, err := ValidateAllowingHosts("http://10.1.2.3/mcp", allowed); err != nil {
|
|
t.Fatalf("expected allowlisted private IP to pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAllowingHosts_RejectsNonAllowlistedPrivateIP(t *testing.T) {
|
|
allowed := map[string]bool{"10.9.9.9": true}
|
|
if _, _, err := ValidateAllowingHosts("http://10.1.2.3/mcp", allowed); err == nil {
|
|
t.Fatal("expected non-allowlisted private IP to be rejected")
|
|
}
|
|
}
|
|
|
|
// The metadata / link-local range must never be exempted, even if an operator
|
|
// explicitly allowlists it — this is the crown-jewel SSRF target.
|
|
func TestValidateAllowingHosts_NeverExemptsMetadataEndpoint(t *testing.T) {
|
|
allowed := map[string]bool{"169.254.169.254": true}
|
|
if _, _, err := ValidateAllowingHosts("http://169.254.169.254/latest/meta-data", allowed); err == nil {
|
|
t.Fatal("metadata/link-local must never be exempted by the allowlist")
|
|
}
|
|
}
|
|
|
|
func TestValidateAllowingHosts_NilAllowlistMatchesValidate(t *testing.T) {
|
|
if _, _, err := ValidateAllowingHosts("http://10.1.2.3/", nil); err == nil {
|
|
t.Fatal("nil allowlist must reject private IPs like Validate")
|
|
}
|
|
}
|
|
|
|
func TestValidateAllowingHosts_PublicIPUnaffected(t *testing.T) {
|
|
if _, _, err := ValidateAllowingHosts("http://8.8.8.8/", map[string]bool{"10.0.0.1": true}); err != nil {
|
|
t.Fatalf("public IP should pass regardless of allowlist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAllowingHosts_AllowsAllowlistedLoopback(t *testing.T) {
|
|
allowed := map[string]bool{"127.0.0.1": true}
|
|
if _, _, err := ValidateAllowingHosts("http://127.0.0.1/mcp", allowed); err != nil {
|
|
t.Fatalf("expected allowlisted loopback to pass, got %v", err)
|
|
}
|
|
}
|