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:
parent
fec7ac82d3
commit
48ba0a1332
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user