diff --git a/platform/internal/handlers/registry.go b/platform/internal/handlers/registry.go index fd10971a..107166c7 100644 --- a/platform/internal/handlers/registry.go +++ b/platform/internal/handlers/registry.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "log" + "net" "net/http" + "net/url" "strings" "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" @@ -23,6 +25,36 @@ func NewRegistryHandler(b *events.Broadcaster) *RegistryHandler { return &RegistryHandler{broadcaster: b} } +// validateAgentURL rejects URLs that could be used as SSRF vectors against +// cloud metadata services or other internal infrastructure. +// +// Allowed: http:// or https:// only (no file://, ftp://, etc.). +// Blocked: 169.254.0.0/16 (link-local — AWS/GCP/Azure metadata endpoints). +// Allowed: RFC-1918 private ranges (Docker networking uses 172.16–31.x.x). +// +// Returns a non-nil error string suitable for including in a 400 response. +func validateAgentURL(rawURL string) error { + if rawURL == "" { + return errors.New("url is required") + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("url is not valid: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("url scheme must be http or https, got %q", parsed.Scheme) + } + hostname := parsed.Hostname() + if ip := net.ParseIP(hostname); ip != nil { + // Block 169.254.0.0/16 — cloud metadata (AWS IMDSv1/v2, GCP, Azure). + _, linkLocal, _ := net.ParseCIDR("169.254.0.0/16") + if linkLocal.Contains(ip) { + return errors.New("url targets a link-local address (cloud metadata endpoint)") + } + } + return nil +} + // Register handles POST /registry/register // Upserts workspace, sets Redis TTL, broadcasts WORKSPACE_ONLINE. func (h *RegistryHandler) Register(c *gin.Context) { @@ -32,6 +64,12 @@ func (h *RegistryHandler) Register(c *gin.Context) { return } + // C6: reject SSRF-capable URLs before persisting or caching them. + if err := validateAgentURL(payload.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx := c.Request.Context() agentCardStr := string(payload.AgentCard) diff --git a/platform/internal/handlers/registry_test.go b/platform/internal/handlers/registry_test.go index b21fb1b0..e7cf5f58 100644 --- a/platform/internal/handlers/registry_test.go +++ b/platform/internal/handlers/registry_test.go @@ -433,3 +433,41 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) { t.Errorf("#73 guard not present in heartbeat UPDATE SQL: %v", err) } } + +// ------------------------------------------------------------ +// validateAgentURL (C6 SSRF fix) +// ------------------------------------------------------------ + +func TestValidateAgentURL(t *testing.T) { + cases := []struct { + name string + url string + wantErr bool + }{ + // Valid Docker-internal URLs (must be allowed). + {"valid docker http", "http://172.18.0.5:8000", false}, + {"valid localhost http", "http://127.0.0.1:8000", false}, + {"valid https", "https://agent.example.com:443", false}, + {"valid RFC1918 10.x", "http://10.0.0.5:8080", false}, + {"valid RFC1918 192.168.x", "http://192.168.1.100:8080", false}, + // SSRF vectors that must be rejected. + {"empty url", "", true}, + {"link-local IMDS AWS", "http://169.254.169.254/latest/meta-data/", true}, + {"link-local IMDS GCP", "http://169.254.169.254/computeMetadata/v1/", true}, + {"link-local other", "http://169.254.0.1/anything", true}, + {"non-http scheme file", "file:///etc/passwd", true}, + {"non-http scheme ftp", "ftp://internal-server/secrets", true}, + {"malformed url", "://not-a-url", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAgentURL(tc.url) + if tc.wantErr && err == nil { + t.Errorf("validateAgentURL(%q) = nil, want error", tc.url) + } + if !tc.wantErr && err != nil { + t.Errorf("validateAgentURL(%q) = %v, want nil", tc.url, err) + } + }) + } +}