mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
fix(sub): treat external subscription providers as untrusted input
A client's external subscription is fetched from a third party on every subscriber refresh, and the fetcher trusted it more than its siblings: - it dialled any address, redirects included; it now goes through the SSRF guard like the outbound subscription and remote routing fetchers; - it forwarded any line containing "://"; it now keeps only server share links, so app deeplinks and plain URLs never reach the subscriber's app; - it bounded the body but not the link count (now 1000) or the headers (now 64 KiB), and stored the raw error, URL included, on every row; the stored error is now host-only and at most 1 KiB. The link parser also refuses links that nest JSON past 16 levels, since the JSON subscription pretty-prints it at quadratic cost, and external configs are marshalled compactly until the final, single indent pass.
This commit is contained in:
@@ -2,6 +2,7 @@ package sub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -17,6 +18,17 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// allowLoopbackProvider lets a test serve its provider from httptest on 127.0.0.1, which
|
||||
// the guarded production dialer refuses; every other transport limit stays.
|
||||
func allowLoopbackProvider(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := subscriptionHTTPClient
|
||||
transport := previous.Transport.(*http.Transport).Clone()
|
||||
transport.DialContext = (&net.Dialer{}).DialContext
|
||||
subscriptionHTTPClient = &http.Client{Timeout: previous.Timeout, Transport: transport}
|
||||
t.Cleanup(func() { subscriptionHTTPClient = previous })
|
||||
}
|
||||
|
||||
func resetSubscriptionCache(t *testing.T) {
|
||||
t.Helper()
|
||||
subscriptionCache.Lock()
|
||||
@@ -34,6 +46,7 @@ func resetSubscriptionCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
resetSubscriptionCache(t)
|
||||
var requests atomic.Int32
|
||||
release := make(chan struct{})
|
||||
@@ -69,6 +82,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
resetSubscriptionCache(t)
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -96,6 +110,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
resetSubscriptionCache(t)
|
||||
stale := []string{"vless://stale@example.com:443"}
|
||||
release := make(chan struct{})
|
||||
@@ -150,6 +165,7 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
|
||||
}
|
||||
|
||||
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
|
||||
}))
|
||||
@@ -165,6 +181,7 @@ func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
link := "vless://example"
|
||||
body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1)
|
||||
if len(body) != subscriptionMaxBytes {
|
||||
@@ -185,6 +202,7 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
initMutDB(t)
|
||||
resetSubscriptionCache(t)
|
||||
db := database.GetDB()
|
||||
@@ -302,6 +320,7 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
resetSubscriptionCache(t)
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db"))
|
||||
|
||||
@@ -330,6 +349,7 @@ func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
|
||||
allowLoopbackProvider(t)
|
||||
resetSubscriptionCache(t)
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua-default.db"))
|
||||
|
||||
@@ -348,3 +368,61 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
|
||||
t.Fatalf("User-Agent = %q, want default v2rayNG/1.8.5", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider chooses where the fetch goes, every redirect included, so the dial itself
|
||||
// must refuse this host and its network.
|
||||
func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = w.Write([]byte("vless://uuid@example.com:443#internal"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if links, err := doFetchSubscriptionLinks(srv.URL + "/admin"); err == nil || links != nil {
|
||||
t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("the loopback target received %d request(s)", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch errors are stored on every row holding the URL and shown in the panel: they may
|
||||
// name the host, never the URL (a credential), and a provider must not size them.
|
||||
func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing.T) {
|
||||
initMutDB(t)
|
||||
resetSubscriptionCache(t)
|
||||
allowLoopbackProvider(t)
|
||||
const token = "s3cr3t-token"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Junk", strings.Repeat("j", 128<<10))
|
||||
_, _ = w.Write([]byte("vless://uuid@example.com:443#Node"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
rawURL := srv.URL + "/sub/" + token
|
||||
if err := database.GetDB().Create(&model.ClientExternalLink{Kind: model.ExternalLinkKindSubscription, Value: rawURL}).Error; err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
result := fetchSubscriptionLinks(rawURL)
|
||||
if result.err == nil {
|
||||
t.Fatal("a provider with 128 KiB of headers was accepted")
|
||||
}
|
||||
recordExternalSubscriptionFetch(rawURL, result.err)
|
||||
var row model.ClientExternalLink
|
||||
if err := database.GetDB().Where("value = ?", rawURL).First(&row).Error; err != nil {
|
||||
t.Fatalf("load link: %v", err)
|
||||
}
|
||||
if row.LastFetchError == "" || strings.Contains(row.LastFetchError, token) || len(row.LastFetchError) > 1100 {
|
||||
t.Fatalf("last_fetch_error = %.200q (%d bytes), want a short error without the URL", row.LastFetchError, len(row.LastFetchError))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoFetchSubscriptionLinksRefusesTooManyLinks(t *testing.T) {
|
||||
allowLoopbackProvider(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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user