Files
goclaw/internal/security/ssrf_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

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