From c3d4a153b341ca2c991d14dc973289a8633dd7f4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 25 Oct 2026 15:54:42 +0100 Subject: [PATCH] fix(sub): pin fp=unsafe on links that carry cipher suites Xray builds the ClientHello with uTLS for every fingerprint except "unsafe", and GetFingerprint("") resolves to chrome, so cipherSuites are dropped unless the fingerprint is exactly "unsafe". A host or inbound that set cs and left the fingerprint blank shipped links whose cs did nothing in v2rayN, PattN and every other Xray-based client. Raw links (query and vmess JSON), the JSON subscription and the panel's own link generator now emit fp=unsafe when cs is present and the final fingerprint is empty. An explicit fingerprint is left as configured. --- frontend/src/lib/xray/inbound-link.ts | 17 ++++++ frontend/src/test/inbound-link.test.ts | 74 +++++++++++++++++++++++ internal/sub/host_sub_test.go | 83 ++++++++++++++++++++++++++ internal/sub/json_service.go | 7 +++ internal/sub/service.go | 16 +++++ internal/sub/service_test.go | 30 ++++++++++ 6 files changed, 227 insertions(+) diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 0ed703cd9..e50f4cf4a 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -15,6 +15,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm'; +import { UTLS_FINGERPRINT } from '@/schemas/primitives'; import { hasWgQuickHook, joinConfLines } from './conf-lines'; import { getHeaderValue } from './headers'; import { canEnableTlsFlow } from './protocol-capabilities'; @@ -215,6 +216,18 @@ function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): s return ''; } +// Xray drops cipherSuites under every uTLS fingerprint and reads an empty one as +// chrome, so a link carrying cs must ask for unsafe; the sub server does the same. +function pinUnsafeFingerprintForCipherSuites( + link: URLSearchParams | Record, +): void { + if (link instanceof URLSearchParams) { + if (link.get('cs') && !link.get('fp')) link.set('fp', UTLS_FINGERPRINT.UTLS_UNSAFE); + } else if (link.cs && !link.fp) { + link.fp = UTLS_FINGERPRINT.UTLS_UNSAFE; + } +} + function applyExternalProxyTLSObj( externalProxy: ExternalProxyEntry | null | undefined, obj: Record, @@ -345,6 +358,7 @@ export function genVmessLink(input: GenVmessLinkInput): string { } applyExternalProxyTLSObj(externalProxy, obj, tls); + pinUnsafeFingerprintForCipherSuites(obj); return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2)); } @@ -545,6 +559,7 @@ export function genVlessLink(input: GenVlessLinkInput): string { params.set('flow', flow); } + pinUnsafeFingerprintForCipherSuites(params); const url = new URL( `vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`, ); @@ -688,6 +703,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { params.set('security', 'none'); } + pinUnsafeFingerprintForCipherSuites(params); const url = new URL( `trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`, ); @@ -749,6 +765,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string { params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`); } + pinUnsafeFingerprintForCipherSuites(params); const isSS2022 = settings.method.substring(0, 4) === '2022'; const passwords: string[] = []; if (isSS2022) passwords.push(settings.password); diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index bc40e9d77..9b7c873bb 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -22,6 +22,7 @@ import { } from '@/lib/xray/inbound-link'; import { type DbInboundLike, inboundFromDb } from '@/lib/xray/inbound-from-db'; import { InboundSchema } from '@/schemas/api/inbound'; +import { Base64 } from '@/utils'; import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard'; import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask'; @@ -1523,4 +1524,77 @@ describe('isPostQuantumLink', () => { 'TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256', ); }); + + // Xray reads an empty fingerprint as chrome, whose uTLS hello drops cipherSuites, + // so a link carrying cs with no fingerprint must ask for unsafe. + describe('cipherSuites pin fp=unsafe when no fingerprint is set', () => { + function tlsInbound( + protocol: 'vless' | 'vmess' | 'trojan', + fingerprint: string, + cipherSuites = 'TLS_AES_128_GCM_SHA256', + ) { + const [, rawFixture] = fixturesForProtocol(protocol)[0]; + const stream = rawFixture.streamSettings as Record | undefined; + const tls = (stream?.tlsSettings ?? {}) as Record; + return InboundSchema.parse({ + ...rawFixture, + streamSettings: { + ...stream, + security: 'tls', + tlsSettings: { + ...tls, + cipherSuites, + settings: { ...(tls.settings as Record), fingerprint }, + }, + }, + }); + } + + it('VLESS link gets fp=unsafe', () => { + const link = genVlessLink({ + inbound: tlsInbound('vless', ''), + address: 'example.test', + clientId: '11111111-2222-3333-4444-555555555555', + }); + expect(new URL(link).searchParams.get('fp')).toBe('unsafe'); + }); + + it('VLESS link keeps an explicit fingerprint', () => { + const link = genVlessLink({ + inbound: tlsInbound('vless', 'firefox'), + address: 'example.test', + clientId: '11111111-2222-3333-4444-555555555555', + }); + expect(new URL(link).searchParams.get('fp')).toBe('firefox'); + }); + + it('VMess link gets fp=unsafe', () => { + const link = genVmessLink({ + inbound: tlsInbound('vmess', ''), + address: 'example.test', + clientId: '11111111-2222-3333-4444-555555555555', + }); + const obj = JSON.parse(Base64.decode(link.slice('vmess://'.length))) as Record< + string, + unknown + >; + expect(obj.fp).toBe('unsafe'); + }); + + it('Trojan link gets fp=unsafe from an external proxy cipherSuites', () => { + const link = genTrojanLink({ + inbound: tlsInbound('trojan', '', ''), + address: 'example.test', + clientPassword: 'pw', + externalProxy: { + forceTls: 'same', + dest: 'cdn.example.test', + port: 443, + remark: '', + cipherSuites: 'TLS_AES_256_GCM_SHA384', + }, + }); + expect(new URL(link).searchParams.get('fp')).toBe('unsafe'); + }); + }); }); diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index a14d9e7de..e7781e83e 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -847,6 +847,89 @@ func TestSub_HostCipherSuitesJSON(t *testing.T) { } } +// seedCipherSuitesHosts seeds an inbound with no fingerprint and three hosts: +// cipher suites alone, cipher suites under chrome, and neither. +func seedCipherSuitesHosts(t *testing.T) { + t.Helper() + ib := seedSubInbound(t, "s1", "csfp", 4463, 1, + `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":""}}}`) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 0, Remark: "CS", Address: "cs.cdn.com", Port: 443, Security: "tls", + CipherSuites: "TLS_AES_128_GCM_SHA256", + }) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 1, Remark: "CHROME", Address: "chrome.cdn.com", Port: 443, Security: "tls", + CipherSuites: "TLS_AES_128_GCM_SHA256", Fingerprint: "chrome", + }) + seedHost(t, &model.Host{ + InboundId: ib.Id, SortOrder: 2, Remark: "PLAIN", Address: "plain.cdn.com", Port: 443, Security: "tls", + }) +} + +// Xray reads an empty fingerprint as chrome, whose uTLS hello drops cipherSuites: +// a link that carries cs with no fingerprint must ask for unsafe. +func TestSub_HostCipherSuitesPinUnsafeFingerprint_RawLink(t *testing.T) { + seedSubDB(t) + seedCipherSuitesHosts(t) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + want := map[string]string{"cs.cdn.com": "unsafe", "chrome.cdn.com": "chrome", "plain.cdn.com": ""} + for _, raw := range strings.Split(strings.Join(links, "\n"), "\n") { + link, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse raw link %q: %v", raw, err) + } + wantFp, ok := want[link.Hostname()] + if !ok { + t.Fatalf("unexpected link %q", raw) + } + delete(want, link.Hostname()) + if got := link.Query().Get("fp"); got != wantFp { + t.Fatalf("%s: fp = %q, want %q (link %s)", link.Hostname(), got, wantFp, raw) + } + } + if len(want) != 0 { + t.Fatalf("missing links for %v", want) + } +} + +func TestSub_HostCipherSuitesPinUnsafeFingerprint_JSON(t *testing.T) { + seedSubDB(t) + seedCipherSuitesHosts(t) + + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var configs []map[string]any + if err := json.Unmarshal([]byte(out), &configs); err != nil { + t.Fatalf("unmarshal JSON subscription: %v\n%s", err, out) + } + want := map[string]string{"CS": "unsafe", "CHROME": "chrome", "PLAIN": ""} + for _, config := range configs { + outbounds, _ := config["outbounds"].([]any) + proxy, _ := outbounds[0].(map[string]any) + stream, _ := proxy["streamSettings"].(map[string]any) + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + fullRemark, _ := config["remarks"].(string) + remark := strings.Split(fullRemark, "-")[1] + wantFp, ok := want[remark] + if !ok { + t.Fatalf("unexpected config remark %q", fullRemark) + } + delete(want, remark) + if got, _ := tlsSettings["fingerprint"].(string); got != wantFp { + t.Fatalf("%s: fingerprint = %q, want %q (tlsSettings %#v)", remark, got, wantFp, tlsSettings) + } + } + if len(want) != 0 { + t.Fatalf("missing configs for %v", want) + } +} + // Xray reads a client's TLS verification fields at the top of tlsSettings; a // nested "settings" map is panel-only shape and xray silently ignores it. func TestSub_HostTLSVerificationJSONAtXrayLevel(t *testing.T) { diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 795812bf2..7ea81c84a 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -696,6 +696,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c liftHostTLSVerification(newStream) } applyHostStreamOverrides(extPrxy, newStream) + if tlsSettings, ok := newStream["tlsSettings"].(map[string]any); ok && security == "tls" { + cs, _ := tlsSettings["cipherSuites"].(string) + fp, _ := tlsSettings["fingerprint"].(string) + if pinned := fingerprintForCipherSuites(fp, cs); pinned != fp { + tlsSettings["fingerprint"] = pinned + } + } if finalmask, ok := newStream["finalmask"].(map[string]any); ok { newStream["finalmask"] = withLegacyFragmentRanges(finalmask) } diff --git a/internal/sub/service.go b/internal/sub/service.go index d507fac63..b2aaaeacd 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2147,7 +2147,20 @@ func deriveSpiderX(seed, clientKey string) string { return path } +// fingerprintForCipherSuites returns the fingerprint a client needs for cs to apply: +// xray drops cipherSuites under every uTLS fingerprint and reads an empty one as chrome. +func fingerprintForCipherSuites(fp, cs string) string { + if cs != "" && fp == "" { + return "unsafe" + } + return fp +} + func buildVmessLink(obj map[string]any) string { + if cs, _ := obj["cs"].(string); cs != "" { + fp, _ := obj["fp"].(string) + obj["fp"] = fingerprintForCipherSuites(fp, cs) + } jsonStr, _ := json.MarshalIndent(obj, "", " ") return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) } @@ -2482,6 +2495,9 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec } q.Set(k, v) } + if fp := fingerprintForCipherSuites(q.Get("fp"), q.Get("cs")); fp != q.Get("fp") { + q.Set("fp", fp) + } encoded := q.Encode() if encoded != "" { if strings.Contains(link, "?") { diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index 46a5bf36c..1d22259ca 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -634,6 +634,36 @@ func TestBuildVmessLink(t *testing.T) { } } +// Xray drops cipherSuites under every uTLS fingerprint and reads an empty one as +// chrome, so a vmess link with cs and no fingerprint must carry fp=unsafe. +func TestBuildVmessLink_CipherSuitesPinUnsafeFingerprint(t *testing.T) { + cases := []struct { + name string + obj map[string]any + wantFp any + }{ + {"cs with empty fp", map[string]any{"cs": "TLS_AES_128_GCM_SHA256", "fp": ""}, "unsafe"}, + {"cs with no fp key", map[string]any{"cs": "TLS_AES_128_GCM_SHA256"}, "unsafe"}, + {"cs with explicit fp", map[string]any{"cs": "TLS_AES_128_GCM_SHA256", "fp": "firefox"}, "firefox"}, + {"no cs", map[string]any{"fp": ""}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(buildVmessLink(tc.obj), "vmess://")) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + var roundTrip map[string]any + if err := json.Unmarshal(decoded, &roundTrip); err != nil { + t.Fatalf("decoded payload is not JSON: %v", err) + } + if roundTrip["fp"] != tc.wantFp { + t.Fatalf("fp = %#v, want %#v", roundTrip["fp"], tc.wantFp) + } + }) + } +} + func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) { base := map[string]any{ "v": "2",