mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
fix(sub): fetch operator-chosen private and proxied external subscriptions again
Guarding the external subscription fetch against a provider's redirects also refused the URL the operator configured when it names a LAN or loopback address, and the guarded transport dropped the host's proxy setting, so a panel merging another panel on its own network, or one reaching the Internet only through a proxy, silently lost those links. A provider listing more than 1000 links was refused whole as well. A URL that names a private address itself is now fetched, while a hostname still is not, since the provider controls what its DNS answers; public providers stay held to public addresses through every redirect. A fetch the host's proxy setting covers goes through that proxy, and a long list is cut to its first 1000 links instead of refused.
This commit is contained in:
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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 {
|
type subscriptionCacheEntry struct {
|
||||||
links []string
|
links []string
|
||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
@@ -161,13 +192,16 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, service.WithoutRequestURL(err)
|
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.
|
// Some providers gate the link body on a known client User-Agent.
|
||||||
req.Header.Set("User-Agent", externalSubUserAgent())
|
req.Header.Set("User-Agent", externalSubUserAgent())
|
||||||
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
|
// A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559).
|
||||||
if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
|
if hwid := service.ExternalSubscriptionHwid(); hwid != "" {
|
||||||
req.Header.Set("X-HWID", hwid)
|
req.Header.Set("X-HWID", hwid)
|
||||||
}
|
}
|
||||||
resp, err := subscriptionHTTPClient.Do(req)
|
resp, err := subscriptionClientFor(req).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, service.WithoutRequestURL(err)
|
return nil, service.WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
@@ -184,7 +218,8 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
links := decodeSubscriptionBody(body)
|
links := decodeSubscriptionBody(body)
|
||||||
if len(links) > subscriptionMaxLinks {
|
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
|
return links, nil
|
||||||
}
|
}
|
||||||
@@ -192,7 +227,6 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|||||||
var (
|
var (
|
||||||
errBadStatus = &subError{"non-2xx subscription response"}
|
errBadStatus = &subError{"non-2xx subscription response"}
|
||||||
errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"}
|
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
|
// externalSubUserAgent returns the panel setting for external subscription
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -371,6 +372,7 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
|
|||||||
|
|
||||||
// The provider chooses where the fetch goes, every redirect included, so the dial itself
|
// The provider chooses where the fetch goes, every redirect included, so the dial itself
|
||||||
// must refuse this host and its network.
|
// 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) {
|
func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) {
|
||||||
var hits atomic.Int32
|
var hits atomic.Int32
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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"))
|
_, _ = w.Write([]byte("vless://uuid@example.com:443#internal"))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
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)
|
t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err)
|
||||||
}
|
}
|
||||||
if hits.Load() != 0 {
|
if hits.Load() != 0 {
|
||||||
@@ -416,13 +419,48 @@ func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDoFetchSubscriptionLinksRefusesTooManyLinks(t *testing.T) {
|
// A provider listing past the cap is cut to it, not refused: refusing dropped every link
|
||||||
allowLoopbackProvider(t)
|
// the subscription served before the cap existed.
|
||||||
|
func TestDoFetchSubscriptionLinksCapsTooManyLinks(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1)))
|
_, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1)))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
if links, err := doFetchSubscriptionLinks(srv.URL); !errors.Is(err, errSubscriptionTooManyLinks) || links != nil {
|
if links, err := doFetchSubscriptionLinks(srv.URL); err != nil || len(links) != subscriptionMaxLinks {
|
||||||
t.Fatalf("fetch = %d links, %v; want errSubscriptionTooManyLinks", len(links), err)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user