From 1fa8cc785dd3d7e348ccd4676ac115ebae09a538 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:19:44 +0000 Subject: [PATCH] fix(outbound): keep imported links to remote-server settings A share link describes a remote server, but extra=, fm= and the vmess JSON copy provider objects verbatim, so a link could carry keys that only make sense on the importing host. Both link parsers now drop dial-redirect and host file keys (matched case-folded, as the core binds them). Outbound subscriptions also refuse outbounds that dial this host or its network without allowPrivate, any that target the core's own API or metrics port, and any that take their server from DNS records. Rows stored earlier are filtered the same way when the config is built. --- frontend/src/lib/xray/outbound-link-parser.ts | 25 ++++- .../src/test/outbound-link-parser.test.ts | 29 +++++ internal/util/link/outbound.go | 34 +++++- internal/util/link/outbound_test.go | 45 ++++++++ internal/web/service/outbound_subscription.go | 103 ++++++++++++++++-- .../web/service/outbound_subscription_test.go | 51 +++++++++ 6 files changed, 276 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 6e83d988f..bcf2f314b 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null { export function parseOutboundLink(link: string): Raw | null { const trimmed = link.trim(); if (!trimmed) return null; - return ( + const outbound = parseVmessLink(trimmed) ?? parseVlessLink(trimmed) ?? parseTrojanLink(trimmed) ?? parseShadowsocksLink(trimmed) ?? parseHysteria2Link(trimmed) ?? - parseWireguardLink(trimmed) - ); + parseWireguardLink(trimmed); + if (outbound) dropHostLocalKeys(outbound); + return outbound; +} + +// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and +// vmess JSON copy provider objects verbatim, and the core binds keys case-folded. +const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']); + +function dropHostLocalKeys(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(dropHostLocalKeys); + return; + } + if (!value || typeof value !== 'object') return; + const obj = value as Raw; + for (const key of Object.keys(obj)) { + // Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does. + if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key]; + else dropHostLocalKeys(obj[key]); + } } diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 0b6d06d24..830ed8f2c 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -1124,3 +1124,32 @@ describe('obfs=gecko packetSize validation', () => { expect(packetSizeOf(link)).toBeUndefined(); }); }); + +describe('parseOutboundLink — host-local keys', () => { + it('drops keys that redirect the dial or name host files, however they are cased', () => { + const extra = encodeURIComponent( + JSON.stringify({ + downloadSettings: { + address: '8.8.8.8', + port: 443, + sockopt: { dialerProxy: 'api', tcpFastOpen: true }, + tlsSettings: { + serverName: 'cdn.example.com', + certificates: [{ CERTIFICATEFILE: '/dev/zero', keyFile: '/etc/shadow' }], + maſterKeyLog: '/root/keys', + }, + }, + }), + ); + const fm = encodeURIComponent( + JSON.stringify({ tcp: [{ type: 'header-custom', settings: { DialerProxy: 'api' } }] }), + ); + const out = parseOutboundLink( + `vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=${extra}&fm=${fm}#node`, + ); + const json = JSON.stringify(out); + expect(json).not.toMatch(/dialerproxy|certificatefile|keyfile|maſterkeylog/i); + expect(json).toContain('"tcpFastOpen":true'); + expect(json).toContain('"serverName":"cdn.example.com"'); + }); +}); diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index c543dbfc4..34b3e3b7f 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -12,6 +12,7 @@ import ( "math" "net/url" "regexp" + "slices" "strconv" "strings" "time" @@ -118,7 +119,15 @@ func splitLines(s string) []string { // - hysteria2:// (also hy2://) // - wireguard:// (also wg://) func ParseLink(link string) (*ParseResult, error) { - link = strings.TrimSpace(link) + res, err := parseLinkByScheme(strings.TrimSpace(link)) + if err != nil { + return nil, err + } + dropHostLocalKeys(res.Outbound) + return res, nil +} + +func parseLinkByScheme(link string) (*ParseResult, error) { switch { case strings.HasPrefix(link, "vmess://"): return parseVmess(link) @@ -137,6 +146,29 @@ func ParseLink(link string) (*ParseResult, error) { } } +// hostLocalKeys redirect the core's dial or name files on the importing host; extra=, fm= and +// vmess JSON copy provider keys verbatim, and the core binds keys case-folded. +var hostLocalKeys = []string{"dialerProxy", "certificateFile", "keyFile", "masterKeyLog"} + +func dropHostLocalKeys(v any) { + switch v := v.(type) { + case map[string]any: + for key, child := range v { + if slices.ContainsFunc(hostLocalKeys, func(k string) bool { return strings.EqualFold(k, key) }) { + delete(v, key) + continue + } + dropHostLocalKeys(child) + } + case Outbound: + dropHostLocalKeys(map[string]any(v)) + case []any: + for _, child := range v { + dropHostLocalKeys(child) + } + } +} + // --- vmess --- func parseVmess(link string) (*ParseResult, error) { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index ed8c5e6a9..9de81beb4 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -612,3 +612,48 @@ func TestParseLink_PutsUDPHopMaskLast(t *testing.T) { t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) } } + +// A share link describes a remote server; keys that redirect the dial or name files on +// the importing host must not survive, however the core's case-folding decoder spells them. +func TestParseLinkDropsHostLocalKeys(t *testing.T) { + extra := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp",` + + `"sockopt":{"dialerProxy":"api","tcpFastOpen":true},` + + `"tlsSettings":{"serverName":"cdn.example.com","CERTIFICATES":[{"certificateFile":"/dev/zero","keyFile":"/etc/shadow"}],"maſterKeyLog":"/root/keys"}}}`) + fm := url.QueryEscape(`{"tcp":[{"type":"header-custom","settings":{"DialerProxy":"api"}}]}`) + res, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&sni=cdn.example.com&extra=" + extra + "&fm=" + fm + "#node") + if err != nil { + t.Fatalf("parse vless: %v", err) + } + raw, err := json.Marshal(res.Outbound) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var hostLocal []string + var walk func(v any) + walk = func(v any) { + switch v := v.(type) { + case map[string]any: + for k, child := range v { + for _, banned := range []string{"dialerProxy", "certificateFile", "keyFile", "masterKeyLog"} { + if strings.EqualFold(k, banned) { + hostLocal = append(hostLocal, k) + } + } + walk(child) + } + case []any: + for _, child := range v { + walk(child) + } + } + } + var decoded any + _ = json.Unmarshal(raw, &decoded) + walk(decoded) + if len(hostLocal) > 0 { + t.Fatalf("outbound keeps host-local keys %v: %s", hostLocal, raw) + } + if !strings.Contains(string(raw), `"tcpFastOpen":true`) || !strings.Contains(string(raw), `"serverName":"cdn.example.com"`) { + t.Fatalf("remote-server settings were dropped too: %s", raw) + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 47f4c90fc..0cf103972 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "io" + "net" "net/http" "regexp" + "slices" "strconv" "strings" "time" @@ -423,13 +425,19 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } } - // Drop core-rejected links before tagging: prevTagByIndex indexes the persisted - // (filtered) list, so positions must be counted in that same list. - var droppedByCore []string + // Drop refused and core-rejected links before tagging: prevTagByIndex indexes the + // persisted (filtered) list, so positions must be counted in that same list. + var dropped []string + ownPorts := xrayOwnPorts() keptLinks, keptIdentities := parsed[:0], identities[:0] for i, ob := range parsed { - if _, dropped := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(dropped) > 0 { - droppedByCore = append(droppedByCore, dropped...) + if reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts); reason != "" { + logger.Warningf("outbound sub %d: dropping outbound %.64q: %s", sub.Id, ob["tag"], reason) + dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason)) + continue + } + if _, rejected := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(rejected) > 0 { + dropped = append(dropped, rejected...) continue } keptLinks = append(keptLinks, ob) @@ -461,8 +469,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript sub.LinkIdentities = string(identJSON) sub.LastUpdated = time.Now().Unix() sub.LastError = "" - if len(droppedByCore) > 0 { - sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0]) + if len(dropped) > 0 { + sub.LastError = fmt.Sprintf("dropped %d outbound(s): %s", len(dropped), dropped[0]) } if err := database.GetDB().Save(sub).Error; err != nil { @@ -561,6 +569,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil { return nil, nil, err } + ownPorts := xrayOwnPorts() for _, sub := range subs { if strings.TrimSpace(sub.LastFetchedOutbounds) == "" { continue @@ -570,6 +579,14 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err) continue } + // Rows stored before the fetch refused these, or while allowPrivate was on. + arr = slices.DeleteFunc(arr, func(ob any) bool { + reason := internalDialTarget(ob, sub.AllowPrivate, ownPorts) + if reason != "" { + logger.Warningf("outbound sub %d: dropping a stored outbound: %s", sub.Id, reason) + } + return reason != "" + }) arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr) if sub.Prepend { prepend = append(prepend, arr...) @@ -672,3 +689,75 @@ Consequences for balancers / routing: We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription outbounds are always injected at runtime in GetXrayConfig. */ + +// xrayOwnPorts lists the ports of Xray's gRPC API and metrics listeners, which ask +// for no credentials: being reachable from loopback only is their whole protection. +func xrayOwnPorts() []string { + ports := []string{strconv.Itoa(reservedAPIPort())} + metrics, _ := (&XrayMetricsService{}).discoverListen() + if _, port, err := net.SplitHostPort(metrics); err == nil { + ports = append(ports, port) + } + return ports +} + +// internalDialTarget says why a subscription outbound must not be loaded, or "". Keys +// match at any depth (xhttp downloadSettings is a second server) and case-folded like the core. +func internalDialTarget(outbound any, allowPrivate bool, ownPorts []string) string { + switch v := outbound.(type) { + case map[string]any: + for key, child := range v { + text, isText := child.(string) + switch { + case strings.EqualFold(key, "port"): + // A public name may resolve to loopback by dial time, so the port alone decides. + if slices.Contains(ownPorts, fmt.Sprint(child)) { + return "its port belongs to Xray's own API or metrics listener" + } + case strings.EqualFold(key, "addressPortStrategy"): + if text != "" && !strings.EqualFold(text, "none") { + return "addressPortStrategy takes its server from DNS records" + } + case strings.EqualFold(key, "address"), strings.EqualFold(key, "endpoint"): + // WireGuard's settings.address is a list of tunnel addresses, never dialled. + if !isText { + break + } + if _, port, err := net.SplitHostPort(strings.TrimSpace(text)); err == nil && slices.Contains(ownPorts, port) { + return "its port belongs to Xray's own API or metrics listener" + } + if !allowPrivate && namesInternalHost(text) { + return fmt.Sprintf("it connects to the private/internal address %.64q and allowPrivate is off", text) + } + } + if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" { + return reason + } + } + case []any: + for _, child := range v { + if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" { + return reason + } + } + } + return "" +} + +// namesInternalHost reports whether host, with or without a port, names this host or +// its network once xray-core and Go's dialer have read it. +func namesInternalHost(host string) bool { + host = strings.TrimSpace(host) + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[] ") + host, _, _ = strings.Cut(host, "%") + if ip := net.ParseIP(host); ip != nil { + return isBlockedIP(ip) + } + // An empty host is the local system and a name without a dot comes from + // /etc/hosts or the search domain ("env:" has none either). + host = strings.ToLower(strings.TrimSuffix(host, ".")) + return !strings.Contains(host, ".") || host == "localhost" || strings.HasSuffix(host, ".localhost") +} diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 3054ce86f..d5488c55a 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "slices" "strings" "testing" @@ -453,3 +454,53 @@ func outboundsContainTag(outbounds []any, tag string) bool { } return false } + +// The core dials a subscription outbound's server itself, so a provider must not point +// one at Xray's credential-less API, whatever allowPrivate says, nor steer the dial past the check. +func TestOutboundSubscriptionRefreshDropsLinksIntoXrayListeners(t *testing.T) { + setupSettingTestDB(t) + const uuid = "00000000-0000-4000-8000-000000000000" + srv := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","sockopt":{"addressPortStrategy":"SrvPortAndAddress"}}}`) + subID := serveOutboundSubscription(t, "p-", func(int) string { + return "vless://" + uuid + "@127.0.0.1:62789?security=tls&type=tcp#Api\n" + + "vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" + + "vless://" + uuid + "@8.8.8.8:443?security=tls&type=xhttp&extra=" + srv + "#Srv" + }) + tags, _ := refreshOutboundTags(t, subID) + if !slices.Equal(tags, []string{"p-b"}) { + t.Fatalf("stored tags = %v, want only p-b", tags) + } + sub, err := (&OutboundSubscriptionService{}).Get(subID) + if err != nil || !strings.Contains(sub.LastError, "dropped 2 outbound(s)") { + t.Fatalf("lastError = %q (%v), want the two refused links reported", sub.LastError, err) + } +} + +func TestActiveOutboundsDropStoredPrivateDialTargets(t *testing.T) { + setupSettingTestDB(t) + stored := `[{"tag":"p-lan","protocol":"vless","settings":{"address":"10.0.0.7","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}},` + + `{"tag":"p-pub","protocol":"vless","settings":{"address":"8.8.8.8","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}}]` + sub := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "p-", Enabled: true, LastFetchedOutbounds: stored} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + obs, err := (&OutboundSubscriptionService{}).AllActiveOutbounds() + if err != nil { + t.Fatalf("AllActiveOutbounds: %v", err) + } + if outboundsContainTag(obs, "p-lan") || !outboundsContainTag(obs, "p-pub") { + t.Fatalf("active outbounds = %v, want the stored LAN target dropped and the public one kept", obs) + } +} + +func TestNamesInternalHost(t *testing.T) { + for host, want := range map[string]bool{ + "127.0.0.1": true, " [::1]:62789 ": true, "fe80::1%eth0": true, "10.1.2.3:80": true, + "localhost": true, "api.localhost.": true, "intranet": true, "": true, "env:HOME": true, + "8.8.8.8": false, "cdn.example.com": false, "[2001:4860:4860::8888]:443": false, + } { + if got := namesInternalHost(host); got != want { + t.Errorf("namesInternalHost(%q) = %v, want %v", host, got, want) + } + } +}