diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index adf7f4a21..52e0c2087 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -4,7 +4,9 @@ import ( "context" "encoding/base64" "io" + "net" "net/http" + "net/url" "slices" "strings" "sync" @@ -40,6 +42,35 @@ var subscriptionHTTPClient = &http.Client{ }, } +// subscriptionProxy is the host's proxy setting: the proxy is the operator's and does the +// dialling, so a proxied fetch goes through it as the default transport did. +var subscriptionProxy = http.ProxyFromEnvironment + +var proxiedSubscriptionHTTPClient = &http.Client{ + Timeout: 6 * time.Second, + Transport: &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { return subscriptionProxy(r) }, + TLSHandshakeTimeout: 5 * time.Second, + MaxResponseHeaderBytes: 64 << 10, + IdleConnTimeout: 90 * time.Second, + }, +} + +// subscriptionClientFor picks the proxied client when the host's proxy setting covers req. +func subscriptionClientFor(req *http.Request) *http.Client { + if proxyURL, err := subscriptionProxy(req); err == nil && proxyURL != nil { + return proxiedSubscriptionHTTPClient + } + return subscriptionHTTPClient +} + +// operatorChosePrivateHost reports a URL naming a private address itself: the operator picked +// it. A hostname does not count, since the provider controls what its DNS answers. +func operatorChosePrivateHost(u *url.URL) bool { + ip := net.ParseIP(u.Hostname()) + return ip != nil && netsafe.IsBlockedIP(ip) +} + type subscriptionCacheEntry struct { links []string fetchedAt time.Time @@ -161,13 +192,16 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { if err != nil { return nil, service.WithoutRequestURL(err) } + // A provider on the operator's own network is fetched; the hops a public provider picks by + // redirecting stay held to public addresses. + req = req.WithContext(netsafe.ContextWithAllowPrivate(req.Context(), operatorChosePrivateHost(req.URL))) // Some providers gate the link body on a known client User-Agent. req.Header.Set("User-Agent", externalSubUserAgent()) // A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559). if hwid := service.ExternalSubscriptionHwid(); hwid != "" { req.Header.Set("X-HWID", hwid) } - resp, err := subscriptionHTTPClient.Do(req) + resp, err := subscriptionClientFor(req).Do(req) if err != nil { return nil, service.WithoutRequestURL(err) } @@ -184,7 +218,8 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { } links := decodeSubscriptionBody(body) if len(links) > subscriptionMaxLinks { - return nil, errSubscriptionTooManyLinks + logger.Warningf("sub: an external subscription lists %d share links; serving the first %d", len(links), subscriptionMaxLinks) + links = links[:subscriptionMaxLinks] } return links, nil } @@ -192,7 +227,6 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { var ( errBadStatus = &subError{"non-2xx subscription response"} errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"} - errSubscriptionTooManyLinks = &subError{"subscription lists more than 1000 share links"} ) // externalSubUserAgent returns the panel setting for external subscription diff --git a/internal/sub/external_subscription_test.go b/internal/sub/external_subscription_test.go index e1565a37d..04f9c5ef9 100644 --- a/internal/sub/external_subscription_test.go +++ b/internal/sub/external_subscription_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "path/filepath" "strconv" "strings" @@ -371,6 +372,7 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) { // The provider chooses where the fetch goes, every redirect included, so the dial itself // must refuse this host and its network. +// A hostname is the provider's to resolve, so one answering with a private address is refused. func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -378,7 +380,8 @@ func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) { _, _ = w.Write([]byte("vless://uuid@example.com:443#internal")) })) defer srv.Close() - if links, err := doFetchSubscriptionLinks(srv.URL + "/admin"); err == nil || links != nil { + viaName := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1) + if links, err := doFetchSubscriptionLinks(viaName + "/admin"); err == nil || links != nil { t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err) } if hits.Load() != 0 { @@ -416,13 +419,48 @@ func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing. } } -func TestDoFetchSubscriptionLinksRefusesTooManyLinks(t *testing.T) { - allowLoopbackProvider(t) +// A provider listing past the cap is cut to it, not refused: refusing dropped every link +// the subscription served before the cap existed. +func TestDoFetchSubscriptionLinksCapsTooManyLinks(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1))) })) defer srv.Close() - if links, err := doFetchSubscriptionLinks(srv.URL); !errors.Is(err, errSubscriptionTooManyLinks) || links != nil { - t.Fatalf("fetch = %d links, %v; want errSubscriptionTooManyLinks", len(links), err) + if links, err := doFetchSubscriptionLinks(srv.URL); err != nil || len(links) != subscriptionMaxLinks { + t.Fatalf("fetch = %d links, %v; want the first %d", len(links), err, subscriptionMaxLinks) + } +} + +// An operator merging a panel on their own network names its address in the URL; refusing +// it lost those links from every subscriber's response after the SSRF guard went in. +func TestDoFetchSubscriptionLinksFetchesOperatorChosenPrivateAddress(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("vless://uuid@example.com:443#lan")) + })) + defer srv.Close() + if links, err := doFetchSubscriptionLinks(srv.URL + "/sub"); err != nil || len(links) != 1 { + t.Fatalf("fetch of an operator-chosen loopback URL = %v, %v; want its link", links, err) + } +} + +// A panel that reaches the Internet only through the host's proxy fetched through it before +// the guarded transport, which had no proxy at all. +func TestDoFetchSubscriptionLinksUsesTheHostProxy(t *testing.T) { + var proxied atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied.Add(1) + _, _ = w.Write([]byte("vless://uuid@example.com:443#via-proxy")) + })) + defer proxy.Close() + proxyURL, _ := url.Parse(proxy.URL) + previous := subscriptionProxy + subscriptionProxy = http.ProxyURL(proxyURL) + t.Cleanup(func() { subscriptionProxy = previous }) + + if links, err := doFetchSubscriptionLinks("http://provider.example/sub"); err != nil || len(links) != 1 { + t.Fatalf("proxied fetch = %v, %v; want the provider's link", links, err) + } + if proxied.Load() != 1 { + t.Fatalf("proxy saw %d requests, want 1", proxied.Load()) } }