fix(security): block SSRF via registry URL validation (C6)

POST /registry/register accepted any URL string and persisted it as
the workspace's A2A endpoint — an attacker could register a workspace
with url=http://169.254.169.254/latest/meta-data/ and cause the platform
to proxy requests to the cloud metadata service when proxying A2A traffic.

Fix: validateAgentURL() helper rejects:
  - empty URL
  - non-http/https schemes (file://, ftp://, etc.)
  - 169.254.0.0/16 link-local IPs (AWS/GCP/Azure IMDS endpoints)
Allows RFC-1918 private ranges (Docker networking uses 172.16-31.x.x).

Adds 12 unit tests covering valid Docker-internal URLs and all SSRF vectors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Lead Agent 2026-04-14 06:37:37 +00:00
parent fec7ac82d3
commit 48ba0a1332
2 changed files with 76 additions and 0 deletions

View File

@ -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.1631.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)

View File

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