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.
This commit is contained in:
MHSanaei
2026-07-15 04:07:04 +02:00
parent 9b258becd0
commit 6b89613ad7
2 changed files with 54 additions and 2 deletions
+21 -2
View File
@@ -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
@@ -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)
}
}