Files
3x-ui/internal/sub/service_sharelink_test.go
T
Farhan Zare 93847dd106 fix(sub): keep the spider settings in a reality spiderX seed's query (#6694)
* fix(sub): keep the spider settings in a reality spiderX seed's query

xray's REALITY client reads p, c, t, i and r from the spiderX query as
its spider's own settings (padding, concurrency, times, interval,
return). deriveSpiderX hashes the whole seed into a bare /path per
client, so any query set on the inbound was dropped from every share
link and JSON subscription, and the spider always ran with defaults.

Keep the seed's query after the derived path. The hash input is
unchanged, so every existing client's spx stays the same; only seeds
that carry a query gain it. The frontend mirror and the cross-language
vectors are updated together.

* docs(sub): keep the deriveSpiderX comments within two lines

Review feedback on #6694: the added lines pushed both doc blocks past the two-line cap.
2026-10-03 00:31:52 +02:00

201 lines
7.0 KiB
Go

package sub
import (
"net/url"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// shareLinkInbound builds a VLESS inbound with one client and the given stream
// settings, mirroring flowTestInbound but without forcing a flow.
func shareLinkInbound(streamSettings string) *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
StreamSettings: streamSettings,
}
}
// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
// (service.go:1029) writes into a TLS share link. Without these assertions a mutant
// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
// the existing flow tests only check `flow=`.
func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"tls",
"tcpSettings":{"header":{"type":"none"}},
"tlsSettings":{
"serverName":"sni.example.com",
"alpn":["h2","http/1.1"],
"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
wants := []string{
"security=tls",
"sni=sni.example.com",
"fp=chrome",
"alpn=h2%2Chttp%2F1.1",
"pcs=YWJj",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("TLS link missing %q\n got: %s", w, link)
}
}
}
// Locks the reality field mapping of applyShareRealityParams; distinct pbk/sid
// catch a swap mutant. spx is now a per-client derived value (#5718 / follow-up).
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{
"security=reality",
"support-x25519mlkem768=true",
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2F",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
}
}
// realityTwoClientInbound builds a reality VLESS inbound carrying two clients
// with distinct subIds so the per-client spx derivation can be exercised.
func realityTwoClientInbound() *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[
{"id":"11111111-2222-4333-8444-555555555555","email":"alice","subId":"subAlice"},
{"id":"22222222-3333-4444-8555-666666666666","email":"bob","subId":"subBob"}
],"decryption":"none","encryption":"none"}`,
StreamSettings: `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`,
}
}
func spxParam(t *testing.T, link string) string {
t.Helper()
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link %q: %v", link, err)
}
spx := u.Query().Get("spx")
if spx == "" || spx[0] != '/' {
t.Fatalf("spx missing or not /-prefixed in %q", link)
}
return spx
}
// spx must be stable for a given client across repeated exports (the #5718
// complaint) yet differ between clients so the value can't be fingerprinted.
func TestGenVlessLink_RealitySpiderXPerClientStable(t *testing.T) {
s := &SubService{}
inbound := realityTwoClientInbound()
aliceFirst := spxParam(t, s.genVlessLink(inbound, "alice"))
aliceSecond := spxParam(t, s.genVlessLink(inbound, "alice"))
bob := spxParam(t, s.genVlessLink(inbound, "bob"))
if aliceFirst != aliceSecond {
t.Fatalf("spx not stable for the same client: %q vs %q", aliceFirst, aliceSecond)
}
if aliceFirst == bob {
t.Fatalf("spx identical across clients (fingerprintable): %q", aliceFirst)
}
}
// A seed's spider settings (p, c, t, i, r) ride along in the share link's spx,
// escaped so they stay inside that one parameter.
func TestGenVlessLink_RealitySpiderXKeepsSpiderSettings(t *testing.T) {
s := &SubService{}
inbound := realityTwoClientInbound()
inbound.StreamSettings = strings.Replace(inbound.StreamSettings, `"spiderX":"/seed"`, `"spiderX":"/seed?p=40-400&r=500-2000"`, 1)
link := s.genVlessLink(inbound, "alice")
if got, want := spxParam(t, link), "/09dd00b3f8c01f5?p=40-400&r=500-2000"; got != want {
t.Fatalf("spx = %q, want %q", got, want)
}
u, _ := url.Parse(link)
if u.Query().Get("p") != "" || u.Query().Get("r") != "" {
t.Fatalf("spider settings leaked out of spx into the link's own query: %q", link)
}
}
func TestDeriveSpiderX(t *testing.T) {
if got := deriveSpiderX("seed", "clientA"); got != deriveSpiderX("seed", "clientA") {
t.Fatalf("deriveSpiderX not deterministic: %q", got)
}
if deriveSpiderX("seed", "clientA") == deriveSpiderX("seed", "clientB") {
t.Fatal("deriveSpiderX must differ per client")
}
if deriveSpiderX("seedA", "clientA") == deriveSpiderX("seedB", "clientA") {
t.Fatal("rotating the seed must rotate a client's spx")
}
got := deriveSpiderX("seed", "clientA")
if len(got) != 16 || got[0] != '/' {
t.Fatalf("deriveSpiderX shape = %q, want /-prefixed 15-char path", got)
}
if fallback := deriveSpiderX("", ""); len(fallback) != 16 || fallback[0] != '/' {
t.Fatalf("empty-input fallback = %q, want /-prefixed path", fallback)
}
}
// Cross-language vectors shared with frontend/src/test/spider-x.test.ts: the
// panel builds these links in TS, so both derivations must agree byte-for-byte.
func TestDeriveSpiderXMatchesFrontendVectors(t *testing.T) {
vectors := map[string]struct{ seed, clientKey, want string }{
"seed and subId": {"/seed", "subAlice", "/c252fbc3ecd3e3c"},
"seed only": {"/", "", "/d08ed99bd9afc60"},
// xray reads p, c, t, i and r from the spiderX query as the spider's
// own settings, so the seed's query must survive the derivation.
"seed with spider settings": {"/seed?p=40-400&r=500-2000", "subAlice", "/09dd00b3f8c01f5?p=40-400&r=500-2000"},
"spider settings only": {"/?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000", "", "/ac2cb268d22908e?p=40-400&c=1-4&t=1-3&i=1500-6000&r=500-2000"},
}
for name, v := range vectors {
t.Run(name, func(t *testing.T) {
if got := deriveSpiderX(v.seed, v.clientKey); got != v.want {
t.Fatalf("deriveSpiderX(%q, %q) = %q, want %q (must match frontend/src/lib/xray/spider-x.ts)", v.seed, v.clientKey, got, v.want)
}
})
}
}