Merge pull request #1469 from Molecule-AI/fix/main-build-dedupe-ssrf
fix(core): resolve main build — remove duplicate SSRF function declarations
This commit is contained in:
commit
d86b8feb36
@ -6,7 +6,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isSafeURL validates that a URL resolves to a publicly-routable address,
|
||||
// preventing A2A requests from being redirected to internal/cloud-metadata
|
||||
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
|
||||
// so we validate before making any outbound HTTP call.
|
||||
func isSafeURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
// Reject non-HTTP(S) schemes.
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("empty hostname")
|
||||
}
|
||||
// Block direct IP addresses.
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
|
||||
return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip)
|
||||
}
|
||||
if isPrivateOrMetadataIP(ip) {
|
||||
return fmt.Errorf("forbidden private/metadata IP: %s", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// For hostnames, resolve and validate each returned IP.
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
// DNS resolution failure — block it. Could be an internal hostname.
|
||||
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("DNS returned no addresses for: %s", host)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) {
|
||||
return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT,
|
||||
// link-local, and cloud metadata ranges.
|
||||
func isPrivateOrMetadataIP(ip net.IP) bool {
|
||||
var privateRanges = []net.IPNet{
|
||||
{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)},
|
||||
{IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)},
|
||||
{IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)},
|
||||
{IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)},
|
||||
{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
|
||||
{IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)},
|
||||
{IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)},
|
||||
{IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)},
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range privateRanges {
|
||||
if r.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateRelPath checks that a file path is relative and does not escape
|
||||
// the destination via absolute paths or ".." traversal. Used by
|
||||
// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure.
|
||||
func validateRelPath(filePath string) error {
|
||||
clean := filepath.Clean(filePath)
|
||||
if filepath.IsAbs(clean) || strings.Contains(clean, "..") {
|
||||
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isSafeURL is defined in mcp.go.
|
||||
// isPrivateOrMetadataIP is defined in mcp.go.
|
||||
// isSafeURL is defined in a2a_proxy.go.
|
||||
// isPrivateOrMetadataIP is defined in a2a_proxy.go.
|
||||
// saasMode is defined in registry.go.
|
||||
|
||||
// TestSaasMode covers the env-resolution ladder so a self-hosted
|
||||
@ -127,6 +127,8 @@ func TestIsPrivateOrMetadataIP_IPv6(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsPrivateOrMetadataIP(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
cases := []struct {
|
||||
name string
|
||||
ipStr string
|
||||
@ -173,6 +175,8 @@ func TestIsPrivateOrMetadataIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsSafeURL(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
cases := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user