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) + } + } +}