package security import ( "context" "fmt" "net" neturl "net/url" "os" "strconv" "strings" "time" "github.com/Wikid82/charon/backend/internal/network" ) // InternalServiceHostAllowlistEnvVar controls which *additional* hostnames (exact matches) // are permitted for internal service HTTP calls (CrowdSec LAPI, Caddy Admin, etc.). // // Default policy remains localhost-only. // Example: CHARON_SSRF_INTERNAL_HOST_ALLOWLIST="crowdsec,caddy" const InternalServiceHostAllowlistEnvVar = "CHARON_SSRF_INTERNAL_HOST_ALLOWLIST" // ParseExactHostnameAllowlist parses a comma-separated list of hostnames into an exact-match set. // // Notes: // - Hostnames are lowercased for comparison. // - Entries containing schemes/paths are ignored. func ParseExactHostnameAllowlist(csv string) map[string]struct{} { out := make(map[string]struct{}) for _, part := range strings.Split(csv, ",") { h := strings.ToLower(strings.TrimSpace(part)) if h == "" { continue } // Reject obvious non-hostname inputs. if strings.Contains(h, "://") || strings.ContainsAny(h, "/@") { continue } out[h] = struct{}{} } return out } // InternalServiceHostAllowlist returns the deny-by-default internal-service hostname allowlist. // // Defaults: localhost-only. Docker/service-name deployments must opt-in via // CHARON_SSRF_INTERNAL_HOST_ALLOWLIST. func InternalServiceHostAllowlist() map[string]struct{} { allow := map[string]struct{}{ "localhost": {}, "127.0.0.1": {}, "::1": {}, } extra := ParseExactHostnameAllowlist(os.Getenv(InternalServiceHostAllowlistEnvVar)) for h := range extra { allow[h] = struct{}{} } return allow } // ValidateInternalServiceBaseURL validates a configured base URL for an internal service. // // Security model: // - host must be an exact match in allowedHosts // - port must match expectedPort (including default ports if omitted) // - proxy env vars must be ignored by callers (client/transport responsibility) // // Returns a normalized base URL (scheme://host:expectedPort) suitable for safe request construction. func ValidateInternalServiceBaseURL(rawURL string, expectedPort int, allowedHosts map[string]struct{}) (*neturl.URL, error) { u, err := neturl.Parse(rawURL) if err != nil { return nil, fmt.Errorf("invalid url format: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("unsupported scheme: %s (only http and https are allowed)", u.Scheme) } if u.User != nil { return nil, fmt.Errorf("urls with embedded credentials are not allowed") } host := strings.ToLower(u.Hostname()) if host == "" { return nil, fmt.Errorf("missing hostname in url") } if _, ok := allowedHosts[host]; !ok { return nil, fmt.Errorf("hostname not allowed: %s", host) } actualPort := 0 if p := u.Port(); p != "" { portNum, perr := strconv.Atoi(p) if perr != nil || portNum < 1 || portNum > 65535 { return nil, fmt.Errorf("invalid port") } actualPort = portNum } else { if u.Scheme == "https" { actualPort = 443 } else { actualPort = 80 } } if actualPort != expectedPort { return nil, fmt.Errorf("unexpected port: %d (expected %d)", actualPort, expectedPort) } // Normalize to a base URL with an explicit expected port. base := &neturl.URL{ Scheme: u.Scheme, Host: net.JoinHostPort(u.Hostname(), strconv.Itoa(expectedPort)), } return base, nil } // ValidationConfig holds options for URL validation. type ValidationConfig struct { AllowLocalhost bool AllowHTTP bool MaxRedirects int Timeout time.Duration BlockPrivateIPs bool } // ValidationOption allows customizing validation behavior. type ValidationOption func(*ValidationConfig) // WithAllowLocalhost permits localhost addresses for testing (default: false). func WithAllowLocalhost() ValidationOption { return func(c *ValidationConfig) { c.AllowLocalhost = true } } // WithAllowHTTP permits HTTP scheme (default: false, HTTPS only). func WithAllowHTTP() ValidationOption { return func(c *ValidationConfig) { c.AllowHTTP = true } } // WithTimeout sets the DNS resolution timeout (default: 3 seconds). func WithTimeout(timeout time.Duration) ValidationOption { return func(c *ValidationConfig) { c.Timeout = timeout } } // WithMaxRedirects sets the maximum number of redirects to follow (default: 0). func WithMaxRedirects(maxRedirects int) ValidationOption { return func(c *ValidationConfig) { c.MaxRedirects = maxRedirects } } // ValidateExternalURL validates a URL for external HTTP requests with comprehensive SSRF protection. // This function provides defense-in-depth against Server-Side Request Forgery attacks by: // 1. Validating URL format and scheme // 2. Resolving DNS and checking all resolved IPs against private/reserved ranges // 3. Blocking access to cloud metadata endpoints (AWS, GCP, Azure) // 4. Enforcing HTTPS by default (configurable) // // Returns: normalized URL string, error // // Security: This function blocks access to: // - Private IP ranges (RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) // - Loopback addresses (127.0.0.0/8, ::1/128) unless AllowLocalhost option is set // - Link-local addresses (169.254.0.0/16, fe80::/10) including cloud metadata endpoints // - Reserved IP ranges (0.0.0.0/8, 240.0.0.0/4, 255.255.255.255/32) // - IPv6 unique local addresses (fc00::/7) // // Example usage: // // // Production use (HTTPS only, no private IPs) // url, err := ValidateExternalURL("https://api.example.com/webhook") // // // Testing use (allow localhost and HTTP) // url, err := ValidateExternalURL("http://localhost:8080/test", // WithAllowLocalhost(), // WithAllowHTTP()) func ValidateExternalURL(rawURL string, options ...ValidationOption) (string, error) { // Apply default configuration config := &ValidationConfig{ AllowLocalhost: false, AllowHTTP: false, MaxRedirects: 0, Timeout: 3 * time.Second, BlockPrivateIPs: true, } // Apply custom options for _, opt := range options { opt(config) } // Phase 1: URL Format Validation u, err := neturl.Parse(rawURL) if err != nil { return "", fmt.Errorf("invalid url format: %w", err) } // Validate scheme - only http/https allowed if u.Scheme != "http" && u.Scheme != "https" { return "", fmt.Errorf("unsupported scheme: %s (only http and https are allowed)", u.Scheme) } // Enforce HTTPS unless explicitly allowed if !config.AllowHTTP && u.Scheme != "https" { return "", fmt.Errorf("http scheme not allowed (use https for security)") } // Validate hostname exists host := u.Hostname() if host == "" { return "", fmt.Errorf("missing hostname in url") } // ENHANCEMENT: Hostname Length Validation (RFC 1035) const maxHostnameLength = 253 if len(host) > maxHostnameLength { return "", fmt.Errorf("hostname exceeds maximum length of %d characters", maxHostnameLength) } // ENHANCEMENT: Suspicious Pattern Detection if strings.Contains(host, "..") { return "", fmt.Errorf("hostname contains suspicious pattern (..)") } // Reject URLs with credentials in authority section if u.User != nil { return "", fmt.Errorf("urls with embedded credentials are not allowed") } // ENHANCEMENT: Port Range Validation if port := u.Port(); port != "" { portNum, parseErr := parsePort(port) if parseErr != nil { return "", fmt.Errorf("invalid port: %w", parseErr) } if portNum < 1 || portNum > 65535 { return "", fmt.Errorf("port out of range: %d", portNum) } // CRITICAL FIX: Allow standard ports 80/443, block other privileged ports standardPorts := map[int]bool{80: true, 443: true} if portNum < 1024 && !standardPorts[portNum] && !config.AllowLocalhost { return "", fmt.Errorf("non-standard privileged port blocked: %d", portNum) } } // Phase 2: Localhost Exception Handling if config.AllowLocalhost { // Check if this is an explicit localhost address if host == "localhost" || host == "127.0.0.1" || host == "::1" { // Normalize and return - localhost is allowed return u.String(), nil } } // Phase 3: DNS Resolution and IP Validation // Resolve hostname with timeout resolver := &net.Resolver{} ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) defer cancel() ips, err := resolver.LookupIP(ctx, "ip", host) if err != nil { return "", fmt.Errorf("dns resolution failed for %s: %w", host, err) } if len(ips) == 0 { return "", fmt.Errorf("no ip addresses resolved for hostname: %s", host) } // Phase 4: Private IP Blocking // Check ALL resolved IPs against private/reserved ranges if config.BlockPrivateIPs { for _, ip := range ips { // ENHANCEMENT: IPv4-mapped IPv6 Detection // Prevent bypass via ::ffff:192.168.1.1 format if ip.To4() != nil && ip.To16() != nil && isIPv4MappedIPv6(ip) { // Extract the IPv4 address from the mapped format ipv4 := ip.To4() if network.IsPrivateIP(ipv4) { return "", fmt.Errorf("connection to private ip addresses is blocked for security (detected IPv4-mapped IPv6: %s)", ip.String()) } } // Check if IP is in private/reserved ranges using centralized network.IsPrivateIP // This includes: // - RFC 1918 private networks (10.x, 172.16.x, 192.168.x) // - Loopback (127.x.x.x, ::1) // - Link-local (169.254.x.x, fe80::) including cloud metadata // - Reserved ranges (0.x.x.x, 240.x.x.x, 255.255.255.255) // - IPv6 unique local (fc00::) if network.IsPrivateIP(ip) { // ENHANCEMENT: Sanitize Error Messages // Don't leak internal IPs in error messages to external users sanitizedIP := sanitizeIPForError(ip.String()) if ip.String() == "169.254.169.254" { return "", fmt.Errorf("access to cloud metadata endpoints is blocked for security (detected: %s)", sanitizedIP) } return "", fmt.Errorf("connection to private ip addresses is blocked for security (detected: %s)", sanitizedIP) } } } // Normalize URL (trim trailing slashes, lowercase host) normalized := u.String() return normalized, nil } // isIPv4MappedIPv6 detects IPv4-mapped IPv6 addresses (::ffff:192.168.1.1). // This prevents SSRF bypass via IPv6 notation of private IPv4 addresses. func isIPv4MappedIPv6(ip net.IP) bool { // IPv4-mapped IPv6 addresses have the form ::ffff:a.b.c.d // In binary: 80 bits of zeros, 16 bits of ones, 32 bits of IPv4 if len(ip) != net.IPv6len { return false } // Check for ::ffff: prefix (10 zero bytes, 2 0xff bytes) for i := 0; i < 10; i++ { if ip[i] != 0 { return false } } return ip[10] == 0xff && ip[11] == 0xff } // parsePort safely parses a port string to an integer. func parsePort(port string) (int, error) { if port == "" { return 0, fmt.Errorf("empty port string") } var portNum int _, err := fmt.Sscanf(port, "%d", &portNum) if err != nil { return 0, fmt.Errorf("port must be numeric: %s", port) } return portNum, nil } // sanitizeIPForError removes sensitive details from IP addresses in error messages. // This prevents leaking internal network topology to external users. func sanitizeIPForError(ip string) string { // For private IPs, show only the first octet to avoid leaking network structure // Example: 192.168.1.100 -> 192.x.x.x parsedIP := net.ParseIP(ip) if parsedIP == nil { return "invalid-ip" } if parsedIP.To4() != nil { // IPv4: show only first octet parts := strings.Split(ip, ".") if len(parts) == 4 { return parts[0] + ".x.x.x" } } else { // IPv6: show only first segment parts := strings.Split(ip, ":") if len(parts) > 0 { return parts[0] + "::" } } return "private-ip" }