fix(pipeline): validate CDN URLs against SSRF
HTTPS-only CDN URLs; блокировка private/loopback/metadata IP и DNS-resolve на fetch; проверка в httpapi при create/preview/patch CDN sources. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:[email protected]/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user