소스 검색

fix(security): dial outbound subscriptions through the SSRF guard

The outbound-subscription fetch validated the URL host once (resolving DNS and
rejecting private targets) but then fetched with a plain HTTP client that
re-resolves the host at dial time, so a subscription domain the attacker controls
could pass validation as a public IP and rebind to 127.0.0.1 / a cloud metadata
endpoint / an internal host for the actual dial — a blind SSRF into the panel's
network. Route the direct fetch (and its redirects) through
netsafe.SSRFGuardedDialContext, which resolves, checks and dials the same IP
atomically, carrying the subscription's AllowPrivate flag on the request context;
a configured egress proxy still dials its loopback bridge unguarded.
MHSanaei 1 일 전
부모
커밋
6b89613ad7
2개의 변경된 파일54개의 추가작업 그리고 2개의 파일을 삭제
  1. 21 2
      internal/web/service/outbound_subscription.go
  2. 33 0
      internal/web/service/outbound_subscription_ssrf_test.go

+ 21 - 2
internal/web/service/outbound_subscription.go

@@ -17,6 +17,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
@@ -277,6 +278,23 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
 }
 
 // fetchAndStore does the actual network + parse + stability + persist work.
+// subscriptionFetchClient builds the HTTP client used to fetch a subscription.
+// A configured panel egress proxy dials the loopback SOCKS bridge (xray handles
+// the real egress), so its localhost dial must not be SSRF-blocked. A direct
+// fetch dials the target itself and re-resolves the hostname at dial time, so it
+// goes through the SSRF-guarded dialer, which resolves, checks and dials the same
+// IP atomically — closing the DNS-rebinding gap left by validating the hostname
+// separately from the dial.
+func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client {
+	if s.settingService.PanelEgressProxyURL() != "" {
+		return s.settingService.NewProxiedHTTPClient(timeout)
+	}
+	return &http.Client{
+		Timeout:   timeout,
+		Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+	}
+}
+
 func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
 	// Re-sanitize on every fetch (handles legacy rows + defense in depth against
 	// any direct DB tampering). Private targets are blocked unless this
@@ -291,7 +309,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	sub.Url = cleanURL // persist the cleaned version
 
-	client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
+	client := s.subscriptionFetchClient(30 * time.Second)
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect chain as well.
@@ -307,7 +325,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 		return rejectPrivateHost(ctx, req.URL.Hostname())
 	}
 
-	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sub.Url, nil)
+	reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate)
+	req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sub.Url, nil)
 	if err != nil {
 		s.recordError(sub, err)
 		return nil, err

+ 33 - 0
internal/web/service/outbound_subscription_ssrf_test.go

@@ -0,0 +1,33 @@
+package service
+
+import (
+	"context"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+)
+
+// The direct subscription-fetch client must dial through the SSRF guard so a
+// subscription host that resolves to a private/internal address (including a
+// DNS-rebinding flip after validation) is blocked at dial time, not connected to.
+func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
+	setupSettingTestDB(t)
+	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
+
+	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
+	if err != nil {
+		t.Fatalf("new request: %v", err)
+	}
+
+	_, err = client.Do(req)
+	if err == nil {
+		t.Fatal("the fetch client dialed a private address instead of blocking it")
+	}
+	if !strings.Contains(err.Error(), "blocked private") {
+		t.Fatalf("expected an SSRF-guard block, got a plain dial error: %v", err)
+	}
+}