package pipeline import ( "context" "fmt" "net" "net/netip" "net/url" "os" "strconv" "strings" "sync" "time" ) func isBlockedCDNIP(ip netip.Addr) bool { if allowPrivateCDNURLs() { return false } if !ip.IsValid() { return true } return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() || ip == netip.MustParseAddr("169.254.169.254") } func allowPrivateCDNURLs() bool { v := strings.TrimSpace(os.Getenv("EVOBGP_CDN_ALLOW_PRIVATE")) return v == "1" || strings.EqualFold(v, "true") } func isBlockedCDNHostname(host string) bool { if allowPrivateCDNURLs() { return false } h := strings.ToLower(strings.TrimSpace(host)) if h == "" || h == "localhost" { return true } if strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".localhost") { return true } return false } // ValidateCDNURL checks CDN source URLs for SSRF-safe HTTPS endpoints (hostname only; no DNS resolve). func ValidateCDNURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", fmt.Errorf("pipeline: cdn url is required") } u, err := url.Parse(raw) if err != nil { return "", fmt.Errorf("pipeline: cdn url invalid: %w", err) } if u.Scheme != "https" { return "", fmt.Errorf("pipeline: cdn url must use https") } if u.User != nil { return "", fmt.Errorf("pipeline: cdn url must not include credentials") } host := strings.TrimSpace(u.Hostname()) if host == "" { return "", fmt.Errorf("pipeline: cdn url missing host") } if isBlockedCDNHostname(host) { return "", fmt.Errorf("pipeline: cdn url blocked host") } if ip, err := netip.ParseAddr(host); err == nil { if isBlockedCDNIP(ip) { return "", fmt.Errorf("pipeline: cdn url blocked host") } } return u.String(), nil } // ResolveCDNURLHost resolves a CDN hostname and rejects private/link-local targets (SSRF at fetch time). func ResolveCDNURLHost(ctx context.Context, raw string) error { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return err } host := strings.TrimSpace(u.Hostname()) if host == "" { return fmt.Errorf("pipeline: cdn url missing host") } if ip, err := netip.ParseAddr(host); err == nil { if isBlockedCDNIP(ip) { return fmt.Errorf("pipeline: cdn url blocked host") } return nil } if isBlockedCDNHostname(host) { return fmt.Errorf("pipeline: cdn url blocked host") } if ok := cdnDNSVerifyCache.hit(host); ok { return nil } if ctx == nil { ctx = context.Background() } resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() ips, err := net.DefaultResolver.LookupIP(resolveCtx, "ip", host) if err != nil { return fmt.Errorf("pipeline: cdn url dns lookup: %w", err) } if len(ips) == 0 { return fmt.Errorf("pipeline: cdn url dns lookup: no addresses") } for _, ip := range ips { addr, ok := netip.AddrFromSlice(ip) if !ok { continue } if isBlockedCDNIP(addr) { return fmt.Errorf("pipeline: cdn url resolves to blocked address") } } cdnDNSVerifyCache.store(host) return nil } // cdnDNSVerifyTTL bounds how long a successful SSRF check is trusted for one hostname. // Failures are never cached: a transient DNS outage must not open an unsafe window, // and a blocked host is rejected before this cache anyway. func cdnDNSVerifyTTL() time.Duration { sec := 300 if s := strings.TrimSpace(os.Getenv("EVOBGP_CDN_DNS_CACHE_TTL_SEC")); s != "" { if v, err := strconv.Atoi(s); err == nil && v > 0 { sec = v } } return time.Duration(sec) * time.Second } type dnsVerifyCache struct { mu sync.Mutex seen map[string]time.Time } var cdnDNSVerifyCache = &dnsVerifyCache{seen: make(map[string]time.Time)} func (c *dnsVerifyCache) hit(host string) bool { c.mu.Lock() defer c.mu.Unlock() at, ok := c.seen[host] return ok && time.Since(at) < cdnDNSVerifyTTL() } func (c *dnsVerifyCache) store(host string) { c.mu.Lock() defer c.mu.Unlock() if c.seen == nil { c.seen = make(map[string]time.Time) } c.seen[host] = time.Now() if len(c.seen) > 4096 { // Size cap for long-running workers: drop expired entries, then the oldest if needed. now := time.Now() for h, at := range c.seen { if now.Sub(at) >= cdnDNSVerifyTTL() { delete(c.seen, h) } } if len(c.seen) > 4096 { var oldestK string var oldestT time.Time first := true for h, at := range c.seen { if first || at.Before(oldestT) { oldestK, oldestT, first = h, at, false } } if oldestK != "" { delete(c.seen, oldestK) } } } }