diff --git a/platform/internal/handlers/registry.go b/platform/internal/handlers/registry.go index 3af08610..bb4aecfb 100644 --- a/platform/internal/handlers/registry.go +++ b/platform/internal/handlers/registry.go @@ -37,6 +37,13 @@ func NewRegistryHandler(b *events.Broadcaster) *RegistryHandler { // - 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 +// - fe80::/10 IPv6 link-local — same threat class as 169.254.x.x +// - ::1/128 IPv6 loopback +// - fc00::/7 IPv6 ULA (RFC-4193 private ranges) +// +// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) is normalised to IPv4 by +// Go's net.ParseIP.To4() before Contains() runs, so the IPv4 rules above +// catch those without a separate entry. // // Returns a non-nil error suitable for including in a 400 Bad Request response. func validateAgentURL(rawURL string) error { @@ -59,16 +66,19 @@ func validateAgentURL(rawURL string) error { 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"}, + {"169.254.0.0/16", "link-local address (cloud metadata endpoint)"}, + {"127.0.0.0/8", "loopback address"}, + {"10.0.0.0/8", "RFC-1918 private address"}, + {"172.16.0.0/12", "RFC-1918 private address"}, + {"192.168.0.0/16", "RFC-1918 private address"}, + {"fe80::/10", "IPv6 link-local address (cloud metadata analogue)"}, + {"::1/128", "IPv6 loopback address"}, + {"fc00::/7", "IPv6 ULA address (RFC-4193 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 fmt.Errorf("url targets a blocked address: %s", r.label) } } } diff --git a/platform/internal/handlers/registry_test.go b/platform/internal/handlers/registry_test.go index 44370360..d38e3679 100644 --- a/platform/internal/handlers/registry_test.go +++ b/platform/internal/handlers/registry_test.go @@ -480,6 +480,17 @@ func TestValidateAgentURL(t *testing.T) { {"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}, + + // ── Must be rejected: IPv6 SSRF vectors (C6 gap) ───────────────────── + // Go's IPv4 CIDRs do not match pure IPv6 addresses via Contains(), so + // each IPv6 range needs an explicit blocklist entry. + {"blocked IPv6 loopback [::1]", "http://[::1]:8080", true}, + {"blocked IPv6 link-local [fe80::1]", "http://[fe80::1]:8080", true}, + {"blocked IPv6 ULA [fd00::1]", "http://[fd00::1]:8080", true}, + // IPv4-mapped IPv6 for a blocked range must also be rejected. + // Go normalises ::ffff:169.254.x.x to IPv4 via To4(), so the existing + // 169.254.0.0/16 entry catches it without a dedicated rule. + {"blocked IPv4-mapped IPv6 link-local", "http://[::ffff:169.254.169.254]:80", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {