mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 01:25:00 +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:
@@ -203,7 +203,8 @@ func parsedExternalOutbound(rawLink string) json_util.RawMessage {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ob["tag"] = "proxy"
|
ob["tag"] = "proxy"
|
||||||
b, err := json.MarshalIndent(ob, "", " ")
|
// Compact: the response is indented once at the end, and each extra pass costs per level.
|
||||||
|
b, err := json.Marshal(ob)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sub
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -176,3 +177,15 @@ func TestClashProxyFromExternalTrojanReality(t *testing.T) {
|
|||||||
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
|
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only server links pass: an app deeplink or a plain URL from the provider would reach the
|
||||||
|
// subscriber's app as a directive (a routing profile, an import) the operator never chose.
|
||||||
|
func TestDecodeSubscriptionBodyKeepsOnlyServerShareLinks(t *testing.T) {
|
||||||
|
body := []byte("VLESS://u@a:1#a\nincy://autorouting/onadd/https://p.example/r.json\nhttps://p.example/page\n" +
|
||||||
|
"happ://routing/add/x\nvpn://payload\ntuic://u:p@a:1#t\nhy2://p@a:1#h\nnot a link://x\n")
|
||||||
|
got := decodeSubscriptionBody(body)
|
||||||
|
want := []string{"VLESS://u@a:1#a", "vpn://payload", "tuic://u:p@a:1#t", "hy2://p@a:1#h"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("decoded links = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
// #6559: the Master panel must send a stable X-HWID when fetching external
|
// #6559: the Master panel must send a stable X-HWID when fetching external
|
||||||
// subscriptions, otherwise an HWID-limited donor answers 404.
|
// subscriptions, otherwise an HWID-limited donor answers 404.
|
||||||
func TestFetchSendsStableHwid(t *testing.T) {
|
func TestFetchSendsStableHwid(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||||
|
|
||||||
var gotHwid string
|
var gotHwid string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"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/netsafe"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,10 +24,21 @@ import (
|
|||||||
const (
|
const (
|
||||||
subscriptionCacheTTL = 5 * time.Minute
|
subscriptionCacheTTL = 5 * time.Minute
|
||||||
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
subscriptionMaxBytes = 2 << 20 // 2 MiB
|
||||||
|
subscriptionMaxLinks = 1000
|
||||||
subscriptionCacheCapacity = 256
|
subscriptionCacheCapacity = 256
|
||||||
)
|
)
|
||||||
|
|
||||||
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
|
// The provider picks every redirect target, so each hop is resolved, checked and dialed by
|
||||||
|
// the SSRF guard; its headers are bounded like its body.
|
||||||
|
var subscriptionHTTPClient = &http.Client{
|
||||||
|
Timeout: 6 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: netsafe.SSRFGuardedDialContext,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
MaxResponseHeaderBytes: 64 << 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
type subscriptionCacheEntry struct {
|
type subscriptionCacheEntry struct {
|
||||||
links []string
|
links []string
|
||||||
@@ -129,7 +143,7 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
|
|||||||
}
|
}
|
||||||
lastFetchError := ""
|
lastFetchError := ""
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
lastFetchError = fetchErr.Error()
|
lastFetchError = common.TruncateStatusText(service.WithoutRequestURL(fetchErr).Error())
|
||||||
}
|
}
|
||||||
if err := database.GetDB().
|
if err := database.GetDB().
|
||||||
Model(&model.ClientExternalLink{}).
|
Model(&model.ClientExternalLink{}).
|
||||||
@@ -138,14 +152,14 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
|
|||||||
"last_fetch_at": time.Now().UnixMilli(),
|
"last_fetch_at": time.Now().UnixMilli(),
|
||||||
"last_fetch_error": lastFetchError,
|
"last_fetch_error": lastFetchError,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
|
logger.Warningf("sub: recording fetch status for an external subscription: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, service.WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
// 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())
|
||||||
@@ -155,7 +169,7 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
resp, err := subscriptionHTTPClient.Do(req)
|
resp, err := subscriptionHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, service.WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
@@ -168,12 +182,17 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
|
|||||||
if len(body) > subscriptionMaxBytes {
|
if len(body) > subscriptionMaxBytes {
|
||||||
return nil, errSubscriptionBodyTooLarge
|
return nil, errSubscriptionBodyTooLarge
|
||||||
}
|
}
|
||||||
return decodeSubscriptionBody(body), nil
|
links := decodeSubscriptionBody(body)
|
||||||
|
if len(links) > subscriptionMaxLinks {
|
||||||
|
return nil, errSubscriptionTooManyLinks
|
||||||
|
}
|
||||||
|
return links, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -193,8 +212,16 @@ type subError struct{ msg string }
|
|||||||
|
|
||||||
func (e *subError) Error() string { return e.msg }
|
func (e *subError) Error() string { return e.msg }
|
||||||
|
|
||||||
// decodeSubscriptionBody handles the common base64-encoded newline list as well
|
// serverShareLinkSchemes are the server links a raw subscription carries on: what the link
|
||||||
// as a plain-text body, returning only the lines that look like share links.
|
// parser reads, what the panel itself writes, and common third-party server schemes.
|
||||||
|
var serverShareLinkSchemes = []string{
|
||||||
|
"vmess", "vless", "trojan", "ss", "ssr", "hysteria", "hysteria2", "hy2", "tuic", "wireguard", "wg",
|
||||||
|
"vpn", "socks", "socks5", "anytls", "juicity", "naive+https", "tg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSubscriptionBody handles the common base64-encoded newline list as well as a
|
||||||
|
// plain-text body, keeping only server share links: app deeplinks and plain URLs would
|
||||||
|
// reach the subscriber's app as directives the operator never chose.
|
||||||
func decodeSubscriptionBody(body []byte) []string {
|
func decodeSubscriptionBody(body []byte) []string {
|
||||||
text := strings.TrimSpace(string(body))
|
text := strings.TrimSpace(string(body))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@@ -210,7 +237,8 @@ func decodeSubscriptionBody(body []byte) []string {
|
|||||||
if ln == "" || strings.HasPrefix(ln, "#") {
|
if ln == "" || strings.HasPrefix(ln, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(ln, "://") {
|
scheme, _, found := strings.Cut(ln, "://")
|
||||||
|
if found && slices.ContainsFunc(serverShareLinkSchemes, func(s string) bool { return strings.EqualFold(s, scheme) }) {
|
||||||
out = append(out, ln)
|
out = append(out, ln)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,17 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"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) {
|
func resetSubscriptionCache(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
subscriptionCache.Lock()
|
subscriptionCache.Lock()
|
||||||
@@ -34,6 +46,7 @@ func resetSubscriptionCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
var requests atomic.Int32
|
var requests atomic.Int32
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
@@ -69,6 +82,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
var requests atomic.Int32
|
var requests 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) {
|
||||||
@@ -96,6 +110,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
|
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
stale := []string{"vless://stale@example.com:443"}
|
stale := []string{"vless://stale@example.com:443"}
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
@@ -150,6 +165,7 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
||||||
|
allowLoopbackProvider(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("a", subscriptionMaxBytes+1)))
|
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
|
||||||
}))
|
}))
|
||||||
@@ -165,6 +181,7 @@ func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
|
func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
link := "vless://example"
|
link := "vless://example"
|
||||||
body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1)
|
body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1)
|
||||||
if len(body) != subscriptionMaxBytes {
|
if len(body) != subscriptionMaxBytes {
|
||||||
@@ -185,6 +202,7 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
|
func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
initMutDB(t)
|
initMutDB(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -302,6 +320,7 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
|
func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db"))
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db"))
|
||||||
|
|
||||||
@@ -330,6 +349,7 @@ func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
|
func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) {
|
||||||
|
allowLoopbackProvider(t)
|
||||||
resetSubscriptionCache(t)
|
resetSubscriptionCache(t)
|
||||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua-default.db"))
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
|||||||
maps.Copy(newConfigJson, s.bakedTemplate())
|
maps.Copy(newConfigJson, s.bakedTemplate())
|
||||||
newConfigJson["outbounds"] = newOutbounds
|
newConfigJson["outbounds"] = newOutbounds
|
||||||
newConfigJson["remarks"] = remark
|
newConfigJson["remarks"] = remark
|
||||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
newConfig, _ := json.Marshal(newConfigJson)
|
||||||
configArray = append(configArray, newConfig)
|
configArray = append(configArray, newConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import "strconv"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
// FormatTraffic formats traffic bytes into human-readable units (B, KB, MB, GB, TB, PB).
|
// FormatTraffic formats traffic bytes into human-readable units (B, KB, MB, GB, TB, PB).
|
||||||
func FormatTraffic(trafficBytes int64) string {
|
func FormatTraffic(trafficBytes int64) string {
|
||||||
@@ -14,3 +17,19 @@ func FormatTraffic(trafficBytes int64) string {
|
|||||||
}
|
}
|
||||||
return strconv.FormatFloat(size, 'f', 2, 64) + units[unitIndex]
|
return strconv.FormatFloat(size, 'f', 2, 64) + units[unitIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxStatusTextBytes bounds an error text a third party shapes before it is stored as a
|
||||||
|
// row's status; legitimate messages are a line or two.
|
||||||
|
const maxStatusTextBytes = 1024
|
||||||
|
|
||||||
|
// TruncateStatusText cuts s to maxStatusTextBytes on a UTF-8 boundary.
|
||||||
|
func TruncateStatusText(s string) string {
|
||||||
|
if len(s) <= maxStatusTextBytes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := maxStatusTextBytes
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return s[:cut] + "... (truncated)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,9 +131,37 @@ func ParseLink(link string) (*ParseResult, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dropHostLocalKeys(res.Outbound)
|
dropHostLocalKeys(res.Outbound)
|
||||||
|
if !nestsWithin(map[string]any(res.Outbound), maxOutboundDepth) {
|
||||||
|
return nil, fmt.Errorf("link nests JSON deeper than %d levels", maxOutboundDepth)
|
||||||
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxOutboundDepth bounds the JSON a link can nest into its outbound (the deepest real shape,
|
||||||
|
// an xhttp download stream's finalmask, is about 11): pretty-printing costs depth squared.
|
||||||
|
const maxOutboundDepth = 16
|
||||||
|
|
||||||
|
func nestsWithin(v any, depth int) bool {
|
||||||
|
if depth < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for _, child := range v {
|
||||||
|
if !nestsWithin(child, depth-1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range v {
|
||||||
|
if !nestsWithin(child, depth-1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parseLinkByScheme(link string) (*ParseResult, error) {
|
func parseLinkByScheme(link string) (*ParseResult, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(link, "vmess://"):
|
case strings.HasPrefix(link, "vmess://"):
|
||||||
|
|||||||
@@ -661,3 +661,17 @@ func TestParseLinkDropsHostLocalKeys(t *testing.T) {
|
|||||||
t.Fatalf("remote-server settings were dropped too: %s", raw)
|
t.Fatalf("remote-server settings were dropped too: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pretty-printing costs the square of the nesting depth, and extra=, fm= and the vmess
|
||||||
|
// JSON copy provider JSON into the outbound; a link may not nest past real shapes.
|
||||||
|
func TestParseLinkRejectsDeeplyNestedJSON(t *testing.T) {
|
||||||
|
deep := strings.Repeat(`{"a":`, 40) + `1` + strings.Repeat(`}`, 40)
|
||||||
|
if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + url.QueryEscape(`{"xmux":`+deep+`}`) + "#n"); err == nil {
|
||||||
|
t.Fatal("a link nesting JSON 40 levels deep was accepted")
|
||||||
|
}
|
||||||
|
real := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","xhttpSettings":{"headers":{"a":"b"}},` +
|
||||||
|
`"finalmask":{"udp":[{"type":"noise","settings":{"noise":[{"type":"rand","packet":"10-20"}]}}]}}}`)
|
||||||
|
if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + real + "#n"); err != nil {
|
||||||
|
t.Fatalf("a real download stream with a finalmask was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (strin
|
|||||||
func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
|
func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
|
||||||
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, common.NewError("invalid subscription URL:", withoutRequestURL(err))
|
return nil, common.NewError("invalid subscription URL:", WithoutRequestURL(err))
|
||||||
}
|
}
|
||||||
if cleanURL == "" {
|
if cleanURL == "" {
|
||||||
return nil, common.NewError("subscription URL is required")
|
return nil, common.NewError("subscription URL is required")
|
||||||
@@ -224,7 +224,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix,
|
|||||||
}
|
}
|
||||||
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewError("invalid subscription URL:", withoutRequestURL(err))
|
return common.NewError("invalid subscription URL:", WithoutRequestURL(err))
|
||||||
}
|
}
|
||||||
if cleanURL == "" {
|
if cleanURL == "" {
|
||||||
return common.NewError("subscription URL is required")
|
return common.NewError("subscription URL is required")
|
||||||
@@ -368,7 +368,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
|||||||
"last_fetched_outbounds": string(obsJSON),
|
"last_fetched_outbounds": string(obsJSON),
|
||||||
"link_identities": fetched.identities,
|
"link_identities": fetched.identities,
|
||||||
"last_updated": time.Now().Unix(),
|
"last_updated": time.Now().Unix(),
|
||||||
"last_error": fetched.lastError,
|
"last_error": common.TruncateStatusText(fetched.lastError),
|
||||||
})
|
})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return nil, res.Error
|
return nil, res.Error
|
||||||
@@ -401,7 +401,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip
|
|||||||
// subscription was explicitly created with AllowPrivate.
|
// subscription was explicitly created with AllowPrivate.
|
||||||
cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
|
cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, withoutRequestURL(err)
|
return nil, WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
if cleanURL == "" {
|
if cleanURL == "" {
|
||||||
return nil, common.NewError("subscription has no valid URL")
|
return nil, common.NewError("subscription has no valid URL")
|
||||||
@@ -429,7 +429,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip
|
|||||||
reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate)
|
reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate)
|
||||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cleanURL, nil)
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cleanURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, withoutRequestURL(err)
|
return nil, WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
userAgent := strings.TrimSpace(sub.UserAgent)
|
userAgent := strings.TrimSpace(sub.UserAgent)
|
||||||
if userAgent == "" {
|
if userAgent == "" {
|
||||||
@@ -443,7 +443,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, withoutRequestURL(err)
|
return nil, WithoutRequestURL(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -557,9 +557,9 @@ func spendXMCBudget(outbound any, budget *int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// withoutRequestURL keeps only the host of the URL net/url and net/http quote in their
|
// WithoutRequestURL keeps only the host of the URL net/url and net/http quote in their
|
||||||
// errors: a subscription URL is a credential, and these errors are logged and stored.
|
// errors: a subscription URL is a credential, and these errors are logged and stored.
|
||||||
func withoutRequestURL(err error) error {
|
func WithoutRequestURL(err error) error {
|
||||||
var urlErr *url.Error
|
var urlErr *url.Error
|
||||||
if !errors.As(err, &urlErr) {
|
if !errors.As(err, &urlErr) {
|
||||||
return err
|
return err
|
||||||
@@ -588,7 +588,7 @@ func forceUserspaceWireguard(outbound any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
|
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
|
||||||
sub.LastError = err.Error()
|
sub.LastError = common.TruncateStatusText(err.Error())
|
||||||
_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
|
_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user