Merge pull request #94 from Molecule-AI/fix/c6-loopback-ssrf
fix(security): C6 — block loopback IP literals in /registry/register
This commit is contained in:
commit
a60477ed1e
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user