- Add URL validation module to block SSRF against private IPs, localhost, link-local, and cloud metadata endpoints (169.254.169.254); applied to WebFetchTool (initial + redirect) and DownloadFile - Fix symlink-based path traversal in validatePath using EvalSymlinks for both workspace and target paths; handle non-existent files by resolving parent directory symlinks; use trailing separator for prefix comparison - Harden shell execution with expanded deny patterns (data exfiltration, reverse shells, base64-to-shell pipes, sensitive system paths) and configurable allow/deny lists via new ExecConfig - Propagate workspace restriction to cron job command execution (was previously hardcoded to restrict=false) - Run Docker container as non-root user (UID 1000) with CPU/memory limits - Validate GitHub repo format in skill installer to prevent URL injection - Tighten file write permissions (0644 -> 0600) and preserve original permissions on file edits - Add comprehensive tests for all security fixes
88 lines
2.2 KiB
Go
88 lines
2.2 KiB
Go
package utils
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// ValidateURL checks that a URL is safe to fetch, blocking private/internal IPs,
|
|
// localhost, link-local addresses, and cloud metadata endpoints.
|
|
func ValidateURL(urlStr string) error {
|
|
parsedURL, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
// Only allow http/https schemes
|
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
|
return fmt.Errorf("only http/https URLs are allowed, got: %s", parsedURL.Scheme)
|
|
}
|
|
|
|
host := parsedURL.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("missing host in URL")
|
|
}
|
|
|
|
// Block localhost variants
|
|
lowerHost := strings.ToLower(host)
|
|
if lowerHost == "localhost" || lowerHost == "ip6-localhost" || lowerHost == "ip6-loopback" {
|
|
return fmt.Errorf("access to localhost is blocked")
|
|
}
|
|
|
|
// Resolve host to IP addresses
|
|
ips, err := net.LookupHost(host)
|
|
if err != nil {
|
|
// If DNS resolution fails, try parsing as IP directly
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return fmt.Errorf("failed to resolve host: %w", err)
|
|
}
|
|
ips = []string{ip.String()}
|
|
}
|
|
|
|
for _, ipStr := range ips {
|
|
ip := net.ParseIP(ipStr)
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
|
|
if err := validateIP(ip); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateIP checks whether an IP address is safe to access.
|
|
func validateIP(ip net.IP) error {
|
|
// Block loopback (127.0.0.0/8, ::1)
|
|
if ip.IsLoopback() {
|
|
return fmt.Errorf("access to loopback address %s is blocked", ip)
|
|
}
|
|
|
|
// Block private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
|
if ip.IsPrivate() {
|
|
return fmt.Errorf("access to private network address %s is blocked", ip)
|
|
}
|
|
|
|
// Block link-local (169.254.0.0/16, fe80::/10)
|
|
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return fmt.Errorf("access to link-local address %s is blocked", ip)
|
|
}
|
|
|
|
// Block unspecified (0.0.0.0, ::)
|
|
if ip.IsUnspecified() {
|
|
return fmt.Errorf("access to unspecified address %s is blocked", ip)
|
|
}
|
|
|
|
// Block cloud metadata endpoints (169.254.169.254)
|
|
metadataIP := net.ParseIP("169.254.169.254")
|
|
if ip.Equal(metadataIP) {
|
|
return fmt.Errorf("access to cloud metadata endpoint %s is blocked", ip)
|
|
}
|
|
|
|
return nil
|
|
}
|