Forráskód Böngészése

fix(sub): SS2022 share links must not base64-encode userinfo (#5432)

Per SIP022, ss:// links for 2022-blake3-* methods must NOT base64-encode
the userinfo; method and password are percent-encoded instead. Clients
like Hiddify reject the base64 form. Fix both the server-side
subscription path and the client-side panel link, plus the matching
parsers for round-trip import.
MHSanaei 1 napja
szülő
commit
a5bc71a6f1

+ 13 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -617,6 +617,19 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
   if (isSS2022) passwords.push(settings.password);
   if (isSSMultiUser) passwords.push(clientPassword);
 
+  if (isSS2022) {
+    // SIP022 (2022-blake3-*) forbids base64 userinfo: method and each key are
+    // percent-encoded, joined by literal ':' separators. Built by hand because
+    // `new URL` would re-encode the inner key separator to %3A.
+    const userinfo = [settings.method, ...passwords].map(encodeURIComponent).join(':');
+    let link = `ss://${userinfo}@${formatUrlHost(address)}:${port}`;
+    const query = params.toString();
+    if (query) link += `?${query}`;
+    link += `#${encodeURIComponent(remark)}`;
+    return link;
+  }
+
+  // SIP002 userinfo is base64(method:pw).
   const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
   const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);

+ 9 - 2
frontend/src/lib/xray/outbound-link-parser.ts

@@ -372,8 +372,15 @@ export function parseShadowsocksLink(link: string): Raw | null {
   const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
   const atIndex = core.indexOf('@');
   if (atIndex >= 0) {
-    try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); }
-    catch { userInfo = core.slice('ss://'.length, atIndex); }
+    const rawUserInfo = core.slice('ss://'.length, atIndex);
+    if (rawUserInfo.includes(':')) {
+      // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
+      // (a literal ':' can't appear in a base64/base64url string).
+      try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; }
+    } else {
+      try { userInfo = Base64.decode(rawUserInfo); }
+      catch { userInfo = rawUserInfo; }
+    }
     const hostPort = core.slice(atIndex + 1);
     const colon = hostPort.lastIndexOf(':');
     if (colon < 0) return null;

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -4,7 +4,7 @@ exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-
 
 exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@override.test:8388?type=tcp#parity-test"`;
+exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`;
 
 exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 
@@ -32,7 +32,7 @@ PersistentKeepalive = 25
 "
 `;
 
-exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@example.test:8388?type=tcp#parity-test"`;
+exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@example.test:8388?type=tcp#parity-test"`;
 
 exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://[email protected]:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`;
 

+ 1 - 1
internal/sub/characterization_test.go

@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
 	}
 	s := &SubService{}
 	got := s.genShadowsocksLink(in, "user")
-	want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+	want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
 	if got != want {
 		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
 	}

+ 12 - 5
internal/sub/service.go

@@ -738,9 +738,16 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host
 	}
 
-	encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)
-	if method[0] == '2' {
-		encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password)
+	// SIP002 userinfo is base64(method:password). For SIP022 (2022-blake3-*) the
+	// userinfo MUST NOT be base64-encoded; method and password are percent-encoded.
+	var userInfo string
+	if strings.HasPrefix(method, "2022") {
+		userInfo = fmt.Sprintf("%s:%s:%s",
+			url.QueryEscape(method),
+			url.QueryEscape(inboundPassword),
+			url.QueryEscape(clients[clientIndex].Password))
+	} else {
+		userInfo = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)))
 	}
 
 	externalProxies, _ := stream["externalProxy"].([]any)
@@ -753,7 +760,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			proxyParams,
 			security,
 			func(dest string, port int) string {
-				return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port))
+				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.endpointRemark(inbound, email, ep)
@@ -761,7 +768,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		)
 	}
 
-	link := fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(address, inbound.Port))
+	link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 

+ 6 - 1
internal/util/link/outbound.go

@@ -344,7 +344,12 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 		hp := core[at+1:]
 		userInfo, err := base64DecodeFlexible(userB64)
 		if err != nil {
-			userInfo = userB64 // not b64, rare
+			// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
+			if dec, uerr := url.QueryUnescape(userB64); uerr == nil {
+				userInfo = dec
+			} else {
+				userInfo = userB64 // not b64, rare
+			}
 		}
 		colon := strings.LastIndex(hp, ":")
 		if colon < 0 {