diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 04e0f7b1a..65e0cc27b 100644 --- a/internal/web/service/outbound_subscription.go +++ b/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 diff --git a/internal/web/service/outbound_subscription_ssrf_test.go b/internal/web/service/outbound_subscription_ssrf_test.go new file mode 100644 index 000000000..205dccc9a --- /dev/null +++ b/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) + } +}