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 {