From 255c658468e48660d8c421c76bf141d0cf2b9d16 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:26:54 +0100 Subject: [PATCH] fix(sub): escape a VLESS id the way the panel's own links do Subscription links escaped the VLESS id with url.QueryEscape, while the panel builds its own link and QR through the browser's URL parser, which escapes userinfo with the WHATWG userinfo set. A non-UUID id such as team+1 was therefore served as team%2B1 by the subscription and as team+1 in the QR, and a client that does not percent-decode userinfo authenticated with a different id depending on where it came from. The id is now escaped with exactly that set, which still keeps a line break or an "@" from splitting the link. --- internal/sub/endpoint_test.go | 23 +++++++++++++++++++++++ internal/sub/service.go | 19 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index ebfa71da7..7d5772fa0 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "testing" @@ -231,3 +232,25 @@ func TestGenVlessLinkEscapesID(t *testing.T) { t.Fatalf("vless id not escaped: %q", got) } } + +// The panel builds its own link and QR through the browser's URL parser, which escapes +// userinfo with the WHATWG userinfo set; a subscription escaping more serves another id. +func TestGenVlessLinkEscapesIDLikeThePanel(t *testing.T) { + for id, want := range map[string]string{ + "team+1": "team+1", + "a=b": "a%3Db", + "x:y;z": "x%3Ay%3Bz", + "ok!$,'()*~": "ok!$,'()*~", + "é": "%C3%A9", + } { + in := &model.Inbound{ + Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n", + Settings: `{"clients":[{"id":` + strconv.Quote(id) + `,"email":"user"}],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + got := NewSubService("").genVlessLink(in, "user") + if !strings.HasPrefix(got, "vless://"+want+"@203.0.113.5:443?") { + t.Errorf("id %q: link %q, want userinfo %q", id, got, want) + } + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 23523b926..2c4c005f7 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1432,7 +1432,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", encodeUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) + return fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep, streamNetwork) @@ -1440,7 +1440,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s", encodeUserinfo(uuid), joinHostPort(address, port)) + link := fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(uuid), joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } @@ -1508,6 +1508,21 @@ func encodeUserinfo(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +// encodeLinkUserinfo escapes with the WHATWG userinfo set, as the panel's own links do through +// the browser's URL parser, so a non-UUID id reads the same in a subscription and a QR. +func encodeLinkUserinfo(s string) string { + const escaped = " \"#<>?`{}/:;=@[\\]^|" + var sb strings.Builder + for _, b := range []byte(s) { + if b < 0x20 || b > 0x7e || strings.IndexByte(escaped, b) >= 0 { + fmt.Fprintf(&sb, "%%%02X", b) + } else { + sb.WriteByte(b) + } + } + return sb.String() +} + // joinHostPort wraps an IPv6 host in square brackets the way RFC 3986 // requires for URI authorities, while leaving IPv4 addresses and hostnames // untouched. It also strips any brackets already present on the input so