quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard. - Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality. - Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions. - Introduced new properties for KPI cards to support progress tracking and improved visual feedback. This cleanup aims to enhance performance and maintainability of the dashboard while providing a better user experience.
181 lines
4.5 KiB
Go
181 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|