diff --git a/platform/internal/handlers/registry.go b/platform/internal/handlers/registry.go index c351c842..3af08610 100644 --- a/platform/internal/handlers/registry.go +++ b/platform/internal/handlers/registry.go @@ -29,10 +29,16 @@ func NewRegistryHandler(b *events.Broadcaster) *RegistryHandler { // 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). +// Allowed: public routable addresses and DNS hostnames (including "localhost"). // -// Returns a non-nil error string suitable for including in a 400 response. +// Blocked IP ranges — agents MUST register using DNS hostnames, not IP literals: +// - 169.254.0.0/16 link-local — AWS/GCP/Azure metadata (IMDSv1/v2) +// - 127.0.0.0/8 loopback — self-SSRF: redirects A2A traffic back to platform +// - 10.0.0.0/8 RFC-1918 — lateral movement within private networks +// - 172.16.0.0/12 RFC-1918 — includes Docker bridge/overlay ranges +// - 192.168.0.0/16 RFC-1918 — home/office LAN ranges +// +// Returns a non-nil error suitable for including in a 400 Bad Request response. func validateAgentURL(rawURL string) error { if rawURL == "" { return errors.New("url is required") @@ -46,10 +52,24 @@ func validateAgentURL(rawURL string) error { } 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)") + // All private and reserved ranges are rejected. Agents must register + // using DNS hostnames so the platform can reach them; raw IP literals + // in registration payloads have no legitimate use case and enable SSRF. + blockedRanges := []struct { + cidr string + label string + }{ + {"169.254.0.0/16", "link-local (cloud metadata endpoint)"}, + {"127.0.0.0/8", "loopback"}, + {"10.0.0.0/8", "RFC-1918 private"}, + {"172.16.0.0/12", "RFC-1918 private"}, + {"192.168.0.0/16", "RFC-1918 private"}, + } + for _, r := range blockedRanges { + _, network, _ := net.ParseCIDR(r.cidr) + if network.Contains(ip) { + return errors.New("private/reserved IP ranges are not permitted") + } } } return nil diff --git a/platform/internal/handlers/registry_test.go b/platform/internal/handlers/registry_test.go index d44b1405..44370360 100644 --- a/platform/internal/handlers/registry_test.go +++ b/platform/internal/handlers/registry_test.go @@ -444,20 +444,42 @@ func TestValidateAgentURL(t *testing.T) { 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}, + // ── Valid URLs (public hostnames / DNS names) ────────────────────────── + {"valid public https", "https://agent.example.com:443", false}, + {"valid public http", "http://agent.example.com:8000", false}, + // localhost by name is allowed — agents in local-dev use this form. + {"valid localhost name", "http://localhost:8000", false}, + + // ── Must be rejected: bad scheme ───────────────────────────────────── + {"blocked scheme file", "file:///etc/passwd", true}, + {"blocked scheme ftp", "ftp://internal-server/secrets", true}, + {"blocked malformed url", "://not-a-url", true}, + {"blocked empty url", "", true}, + + // ── Must be rejected: 169.254.0.0/16 — link-local / cloud metadata ─── + {"blocked link-local IMDS 169.254.169.254", "http://169.254.169.254/latest/meta-data/", true}, + {"blocked link-local GCP metadata", "http://169.254.169.254/computeMetadata/v1/", true}, + {"blocked link-local 169.254.0.1", "http://169.254.0.1/anything", true}, + + // ── Must be rejected: 127.0.0.0/8 — loopback ───────────────────────── + {"blocked loopback 127.0.0.1", "http://127.0.0.1:8080", true}, + {"blocked loopback 127.0.0.2", "http://127.0.0.2:8080", true}, + {"blocked loopback 127.255.255.255", "http://127.255.255.255:9000", true}, + + // ── Must be rejected: 10.0.0.0/8 — RFC-1918 ────────────────────────── + {"blocked RFC1918 10.0.0.1", "http://10.0.0.1:8080", true}, + {"blocked RFC1918 10.0.0.5", "http://10.0.0.5:8080", true}, + {"blocked RFC1918 10.255.255.254", "http://10.255.255.254:8080", true}, + + // ── Must be rejected: 172.16.0.0/12 — RFC-1918 (includes Docker nets) ─ + {"blocked RFC1918 172.16.0.1 (range start)", "http://172.16.0.1:8080", true}, + {"blocked RFC1918 172.18.0.5 (docker bridge)", "http://172.18.0.5:8000", true}, + {"blocked RFC1918 172.31.255.255 (range end)", "http://172.31.255.255:8080", true}, + + // ── Must be rejected: 192.168.0.0/16 — RFC-1918 ────────────────────── + {"blocked RFC1918 192.168.0.1", "http://192.168.0.1:8080", true}, + {"blocked RFC1918 192.168.1.100", "http://192.168.1.100:8080", true}, + {"blocked RFC1918 192.168.255.254", "http://192.168.255.254:8080", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {