diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go index 4fa88e07f..ed60be12f 100644 --- a/internal/sub/external_config.go +++ b/internal/sub/external_config.go @@ -163,7 +163,7 @@ func applyRemarkToLink(rawLink, remark string) string { if i := strings.IndexByte(rawLink, '#'); i >= 0 { rawLink = rawLink[:i] } - return rawLink + "#" + url.PathEscape(remark) + return rawLink + "#" + escapeLinkFragment(remark, url.PathEscape) } func applyVmessRemark(rawLink, remark string) string { diff --git a/internal/sub/external_remark_test.go b/internal/sub/external_remark_test.go new file mode 100644 index 000000000..963f0b9cd --- /dev/null +++ b/internal/sub/external_remark_test.go @@ -0,0 +1,40 @@ +package sub + +import ( + "strings" + "testing" +) + +// #6575: a trailing ?serverDescription= must stay literal in the +// fragment so Happ renders its subtitle; only the display name is escaped. +func TestApplyRemarkKeepsServerDescription(t *testing.T) { + link := "vless://00000000-0000-0000-0000-000000000000@example.com:443?type=tcp&security=reality&pbk=XXX&fp=chrome&sni=example.org&sid=00&flow=xtls-rprx-vision&encryption=none" + remark := "🇵🇱 Warsaw ⚡️?serverDescription=0JTQu9GPIExURSAo0LHQtdC70YvQtSDRgdC/0LjRgdC60Lgp" + + out := applyRemarkToLink(link, remark) + frag := out[strings.IndexByte(out, '#')+1:] + if !strings.Contains(frag, "?serverDescription=") { + t.Fatalf("serverDescription escaped: %s", out) + } + if strings.Contains(frag, "%3F") || strings.Contains(frag, "%2F") { + t.Fatalf("fragment over-escaped: %s", out) + } + tail := frag[strings.Index(frag, "?serverDescription=")+len("?serverDescription="):] + if strings.ContainsAny(tail, " \r\n\t#&") { + t.Fatalf("tail not clean base64: %q", tail) + } + if !strings.HasPrefix(out, link+"#") { + t.Fatalf("link body altered: %s", out) + } +} + +func TestApplyRemarkMalformedServerDescriptionFallsBack(t *testing.T) { + link := "vless://uuid@example.com:443?security=reality#old" + out := applyRemarkToLink(link, "name?serverDescription=not base64!!") + if strings.Contains(out, "?serverDescription=") { + t.Fatalf("malformed tail kept literal: %s", out) + } + if !strings.HasPrefix(out, link[:strings.IndexByte(link, '#')]+"#") { + t.Fatalf("link body altered: %s", out) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 3e14efdac..576e1d7b9 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -2330,22 +2330,29 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec if fragment != "" { sb.WriteByte('#') - if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok { - if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") { - sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20")) - sb.WriteString("?serverDescription=") - sb.WriteString(after) - } else { - sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) - } - } else { - // Match the frontend's encodeURIComponent(remark): spaces become %20. - sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20")) - } + sb.WriteString(escapeLinkFragment(fragment, encodeURIComponent)) } return sb.String() } +// encodeURIComponent matches the frontend's escaping of a remark: spaces become %20. +func encodeURIComponent(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + +// escapeLinkFragment escapes a remark but keeps a valid ?serverDescription= +// tail literal, which Happ reads as the subtitle (#6488, #6575). +func escapeLinkFragment(fragment string, escape func(string) string) string { + before, after, ok := strings.Cut(fragment, "?serverDescription=") + if !ok || after == "" || strings.ContainsAny(after, " \r\n\t#&") { + return escape(fragment) + } + if _, err := base64.StdEncoding.DecodeString(after); err != nil { + return escape(fragment) + } + return escape(before) + "?serverDescription=" + after +} + // buildExternalProxyURLLinks is a thin adapter: it maps the legacy externalProxy // entries to []ShareEndpoint and renders them through the unified endpoint path. // Kept so the genVless/genTrojan/genShadowsocks call sites are unchanged.