From 6b89613ad7976a75556509294477ba5ce95192f6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 04:07:04 +0200 Subject: [PATCH] fix(security): dial outbound subscriptions through the SSRF guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/web/service/outbound_subscription.go | 23 +++++++++++-- .../outbound_subscription_ssrf_test.go | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 internal/web/service/outbound_subscription_ssrf_test.go 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) + } +}