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")
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
|
||||||
return
|
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"))
|
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
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")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
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")
|
mid := r.PathValue("module_id")
|
||||||
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
|
||||||
if err != nil {
|
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")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
return
|
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")
|
mid := r.PathValue("module_id")
|
||||||
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -25,7 +26,7 @@ func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var gotIfNoneMatch string
|
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"))
|
gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match"))
|
||||||
w.Header().Set("ETag", "etag-new")
|
w.Header().Set("ETag", "etag-new")
|
||||||
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
_, _ = 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) {
|
func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -67,7 +69,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -99,6 +101,7 @@ func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -112,7 +115,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -140,6 +143,7 @@ func TestRefreshModuleIngest_CDN304UsesStoredSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
@@ -154,7 +158,7 @@ func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var calls int
|
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++
|
calls++
|
||||||
if calls == 1 {
|
if calls == 1 {
|
||||||
if got := strings.TrimSpace(r.Header.Get("If-None-Match")); got != "etag-stable" {
|
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 == "" {
|
if u == "" {
|
||||||
return nil, nil
|
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)
|
sourceKey := cdnSourceKey(src.ID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,6 +195,12 @@ func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
if u == "" {
|
if u == "" {
|
||||||
return nil, nil
|
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)
|
sourceKey := cdnSourceKey(src.ID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != 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) {
|
func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) {
|
||||||
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "1")
|
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)
|
http.Error(w, "upstream down", http.StatusServiceUnavailable)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -44,8 +45,9 @@ func TestCollectCDNPrefixRows_StaleOnFetchError(t *testing.T) {
|
|||||||
|
|
||||||
func TestCollectCDNPrefixRows_FailFastWhenNoStale(t *testing.T) {
|
func TestCollectCDNPrefixRows_FailFastWhenNoStale(t *testing.T) {
|
||||||
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "0")
|
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)
|
http.Error(w, "upstream down", http.StatusServiceUnavailable)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|||||||
Reference in New Issue
Block a user