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",