From 05abd00bf654ed1d27ba2726f90ea266896443fd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:10:23 +0100 Subject: [PATCH] fix(sub): encode the link host and the VLESS id in share links joinHostPort wrote an endpoint address into the authority of every URL share link unencoded, and genVlessLink alone skipped encodeUserinfo for the client id. Both can come from a node's sync (an externalProxy dest, an adopted host row, the client id), the finished link is never re-parsed, and the raw subscription is one link per line: a line break in either added lines of the node's choosing to the master's subscription, and an id carrying "@" moved the client's VLESS links on other inbounds to another server. Whitespace, control characters and URI delimiters in the host are now percent-encoded, which leaves host names, IP literals and zone ids unchanged, and the VLESS id is encoded as userinfo like every other protocol's credential. --- internal/sub/endpoint_test.go | 50 +++++++++++++++++++++++++++++++++++ internal/sub/service.go | 20 +++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index 8983f3df8..ebfa71da7 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -181,3 +181,53 @@ func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) { t.Fatalf("vmess fm = %q, want the host mask", obj["fm"]) } } + +// An endpoint address is mirrored from the node as reported: whatever it holds, one +// endpoint stays one line of the subscription body, inside its own link's authority. +func TestSub_EndpointAddressCannotAddLines(t *testing.T) { + seedSubDB(t) + dest, _ := json.Marshal("::1]:443#a\nincy://routing/onadd/e30=\r\nvless://x@[::2") + seedSubInbound(t, "s1", "n", 4490, 1, fmt.Sprintf( + `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":%s,"port":443,"remark":""}]}`, dest)) + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + want := "vless://11111111-2222-4333-8444-000000004490@" + + "[::1%5D:443%23a%0Aincy:%2F%2Frouting%2Fonadd%2Fe30=%0D%0Avless:%2F%2Fx%40%5B::2]:443" + + "?encryption=none&security=none&type=tcp#n-n%40e" + if len(links) != 1 || links[0] != want { + t.Fatalf("links = %q, want [%q]", links, want) + } +} + +func TestJoinHostPortEscapesNonHostCharacters(t *testing.T) { + for host, want := range map[string]string{ + "cdn.example.com": "cdn.example.com:443", + "203.0.113.5": "203.0.113.5:443", + "[2001:db8::1]": "[2001:db8::1]:443", + "fe80::1%eth0": "[fe80::1%eth0]:443", + "пример.рф": "пример.рф:443", + "a b\tc": "a%20b%09c:443", + "a\u0085b\u2028c": "a%C2%85b%E2%80%A8c:443", + "h?x=1#f": "h%3Fx=1%23f:443", + "u@h/p": "u%40h%2Fp:443", + } { + if got := joinHostPort(host, 443); got != want { + t.Errorf("joinHostPort(%q) = %q, want %q", host, got, want) + } + } +} + +// The VLESS id is the userinfo of its link and is escaped like every other protocol's. +func TestGenVlessLinkEscapesID(t *testing.T) { + in := &model.Inbound{ + Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n", + Settings: `{"clients":[{"id":"a@b#c\nd","email":"user"}],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + got := NewSubService("").genVlessLink(in, "user") + if !strings.HasPrefix(got, "vless://a%40b%23c%0Ad@203.0.113.5:443?") { + t.Fatalf("vless id not escaped: %q", got) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index b2aaaeacd..89cc785f0 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1359,7 +1359,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params, security, func(ep map[string]any, dest string, port int) string { - return fmt.Sprintf("vless://%s@%s", applyVlessRoute(uuid, hostVlessRoute(ep)), joinHostPort(dest, port)) + return fmt.Sprintf("vless://%s@%s", encodeUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep, streamNetwork) @@ -1367,7 +1367,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port)) + link := fmt.Sprintf("vless://%s@%s", encodeUserinfo(uuid), joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } @@ -1441,7 +1441,21 @@ func encodeUserinfo(s string) string { // callers don't have to normalize upstream. func joinHostPort(host string, port int) string { host = strings.Trim(host, "[]") - return net.JoinHostPort(host, strconv.Itoa(port)) + return net.JoinHostPort(escapeLinkHost(host), strconv.Itoa(port)) +} + +// escapeLinkHost percent-encodes whitespace, control characters and URI delimiters: a +// node-reported address is never re-parsed, so a raw line break would end the link's line. +func escapeLinkHost(host string) string { + var sb strings.Builder + for _, r := range host { + if r == ' ' || !strconv.IsPrint(r) || strings.ContainsRune("\"#/<>?@[\\]^`{|}", r) { + sb.WriteString(encodeURIComponent(string(r))) + } else { + sb.WriteRune(r) + } + } + return sb.String() } func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {