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.
This commit is contained in:
MHSanaei
2026-11-01 00:10:23 +01:00
parent d3df3e9ea7
commit 05abd00bf6
2 changed files with 67 additions and 3 deletions
+50
View File
@@ -181,3 +181,53 @@ func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) {
t.Fatalf("vmess fm = %q, want the host mask", obj["fm"]) 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)
}
}
+17 -3
View File
@@ -1359,7 +1359,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
params, params,
security, security,
func(ep map[string]any, dest string, port int) string { 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 { func(ep map[string]any) string {
return s.endpointRemark(inbound, email, ep, streamNetwork) 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)) 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. // callers don't have to normalize upstream.
func joinHostPort(host string, port int) string { func joinHostPort(host string, port int) string {
host = strings.Trim(host, "[]") 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 { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {