mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 17:16:07 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user