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.
This commit is contained in:
MHSanaei
2026-11-01 10:26:54 +01:00
parent 0394996a30
commit 255c658468
2 changed files with 40 additions and 2 deletions
+23
View File
@@ -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)
}
}
}
+17 -2
View File
@@ -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