diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 0a791cb..a578921 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -242,6 +242,14 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required") return } + if _, err := pipeline.ValidateCDNURL(u); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } + if err := pipeline.ResolveCDNURLHost(r.Context(), u); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id")) if err != nil { writeStoreErr(w, err) @@ -304,6 +312,16 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } + if body.URL != "" { + if _, err := pipeline.ValidateCDNURL(body.URL); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } + if err := pipeline.ResolveCDNURLHost(r.Context(), body.URL); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } + } mid := r.PathValue("module_id") x, err := s.store.CreateCDNSource(a.TenantID, mid, &body) if err != nil { @@ -324,6 +342,16 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } + if body.URL != nil && strings.TrimSpace(*body.URL) != "" { + if _, err := pipeline.ValidateCDNURL(*body.URL); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } + if err := pipeline.ResolveCDNURLHost(r.Context(), *body.URL); err != nil { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail) + return + } + } mid := r.PathValue("module_id") x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body) if err != nil { diff --git a/internal/pipeline/cdn_prefetch_test.go b/internal/pipeline/cdn_prefetch_test.go index 9d20314..52d7156 100644 --- a/internal/pipeline/cdn_prefetch_test.go +++ b/internal/pipeline/cdn_prefetch_test.go @@ -11,6 +11,7 @@ import ( ) func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) { + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") m := store.NewMemory() m.SeedDemo() tenant, _, _, _, _ := m.DemoIDs() @@ -25,7 +26,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) { } var gotIfNoneMatch string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match")) w.Header().Set("ETag", "etag-new") _, _ = w.Write([]byte("198.51.100.0/24\n")) @@ -54,6 +55,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) { } func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) { + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") m := store.NewMemory() m.SeedDemo() tenant, _, _, _, _ := m.DemoIDs() @@ -67,7 +69,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) { t.Fatal(err) } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotModified) })) defer srv.Close() @@ -99,6 +101,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) { } func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) { + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") m := store.NewMemory() m.SeedDemo() tenant, _, _, _, _ := m.DemoIDs() @@ -112,7 +115,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) { t.Fatal(err) } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotModified) })) defer srv.Close() @@ -140,6 +143,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) { } func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) { + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") m := store.NewMemory() m.SeedDemo() tenant, _, _, _, _ := m.DemoIDs() @@ -154,7 +158,7 @@ func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) { } var calls int - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if calls == 1 { if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" { diff --git a/internal/pipeline/cdn_snapshot.go b/internal/pipeline/cdn_snapshot.go index c1316a4..5fe731d 100644 --- a/internal/pipeline/cdn_snapshot.go +++ b/internal/pipeline/cdn_snapshot.go @@ -119,6 +119,12 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl if u == "" { return nil, nil } + if _, err := ValidateCDNURL(u); err != nil { + return nil, err + } + if err := ResolveCDNURLHost(ctx, u); err != nil { + return nil, err + } sourceKey := cdnSourceKey(src.ID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { @@ -189,6 +195,12 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, if u == "" { return nil, nil } + if _, err := ValidateCDNURL(u); err != nil { + return nil, err + } + if err := ResolveCDNURLHost(ctx, u); err != nil { + return nil, err + } sourceKey := cdnSourceKey(src.ID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { diff --git a/internal/pipeline/cdn_url.go b/internal/pipeline/cdn_url.go new file mode 100644 index 0000000..9d67c04 --- /dev/null +++ b/internal/pipeline/cdn_url.go @@ -0,0 +1,116 @@ +package pipeline + +import ( + "context" + "fmt" + "net" + "net/netip" + "net/url" + "os" + "strings" + "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 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") + } + } + return nil +} diff --git a/internal/pipeline/cdn_url_test.go b/internal/pipeline/cdn_url_test.go new file mode 100644 index 0000000..a77d4f1 --- /dev/null +++ b/internal/pipeline/cdn_url_test.go @@ -0,0 +1,34 @@ +package pipeline + +import "testing" + +func TestValidateCDNURL(t *testing.T) { + tests := []struct { + raw string + ok bool + want string + }{ + {"https://cdn.example.com/prefixes.txt", true, "https://cdn.example.com/prefixes.txt"}, + {"http://cdn.example.com/x", false, ""}, + {"https://127.0.0.1/x", false, ""}, + {"https://10.0.0.1/x", false, ""}, + {"https://169.254.169.254/latest/meta-data", false, ""}, + {"https://localhost/x", false, ""}, + {"file:///etc/passwd", false, ""}, + {"https://user:pass@cdn.example.com/x", false, ""}, + } + for _, tc := range tests { + got, err := ValidateCDNURL(tc.raw) + if tc.ok && err != nil { + t.Errorf("%q: unexpected err %v", tc.raw, err) + continue + } + if !tc.ok && err == nil { + t.Errorf("%q: expected error", tc.raw) + continue + } + if tc.ok && got != tc.want { + t.Errorf("%q: got %q want %q", tc.raw, got, tc.want) + } + } +} diff --git a/internal/pipeline/collect_stale_test.go b/internal/pipeline/collect_stale_test.go index 6762348..7299e7d 100644 --- a/internal/pipeline/collect_stale_test.go +++ b/internal/pipeline/collect_stale_test.go @@ -11,8 +11,9 @@ import ( func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) { t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "1") + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "upstream down", http.StatusServiceUnavailable) })) defer srv.Close() @@ -44,8 +45,9 @@ func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) { func TestCollectCDNPrefixRows_FailFastWhenNoStale(t *testing.T) { t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "0") + t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1") - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "upstream down", http.StatusServiceUnavailable) })) defer srv.Close()