From 12dfcb424b249403dfbb10ad3edf47235fca9dae Mon Sep 17 00:00:00 2001 From: "Xsyl.way" <98275209+xuchaoqaq@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:28:27 +0800 Subject: [PATCH] fix(connection): keep equals readable in copied connection urls --- .../connection/connectionUrlBuilder.spec.ts | 32 ++++++++++++++++++- .../lib/connection/connectionUrlBuilder.ts | 16 ++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts index ae826da73c..92d223e528 100644 --- a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts @@ -17,7 +17,10 @@ function config(overrides: Partial): ConnectionUrlCopyC } const ASCII_SYMBOLS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; -const ENCODED_ASCII_SYMBOLS = "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"; +// `=` stays literal (issue #12304): userinfo and query values split on their +// first separator, so escaping it only made copied passwords differ from the +// stored credential for clients that never percent-decode. +const ENCODED_ASCII_SYMBOLS = "%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C=%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"; describe("connectionUrlCopyFormats", () => { it("lists all formats for a PostgreSQL connection with a stored password", () => { @@ -104,6 +107,20 @@ describe("buildConnectionUrlCopy standard URL", () => { expect(buildConnectionUrlCopy(config({ password: "W^j.U" }), "urlWithPassword")).toBe("postgresql://app_user:W%5Ej.U@db.example.com:5432/appdb"); }); + it("keeps `=` readable in copied URLs instead of escaping it as %3D", () => { + const password = "p=ss=word"; + const mysql = config({ db_type: "mysql", port: 3306, password }); + const mysqlUrl = buildConnectionUrlCopy(mysql, "urlWithPassword"); + expect(mysqlUrl).toBe("mysql://app_user:p=ss=word@db.example.com:3306/appdb"); + expect(parseConnectionUrl(mysqlUrl ?? "").password).toBe(password); + + const redis = config({ db_type: "redis", port: 6379, username: "", password, database: "0" }); + expect(buildConnectionUrlCopy(redis, "urlWithPassword")).toBe("redis://:p=ss=word@db.example.com:6379/0"); + + const oceanbase = config({ db_type: "mysql", port: 2883, driver_profile: "oceanbase", password }); + expect(buildConnectionUrlCopy(oceanbase, "urlWithPassword")).toBe("oceanbase://app_user:p=ss=word@db.example.com:2883/appdb"); + }); + it("preserves credentials that already look percent-encoded", () => { const password = "%25%40%5E"; const text = buildConnectionUrlCopy(config({ password }), "urlWithPassword"); @@ -193,6 +210,19 @@ describe("buildConnectionUrlCopy JDBC URL", () => { expect(parseConnectionUrl(text ?? "").password).toBe(password); }); + it("keeps `=` readable in MySQL-family JDBC credentials", () => { + const password = "p=ss=word"; + const mysql = config({ db_type: "mysql", port: 3306, password }); + const mysqlUrl = buildConnectionUrlCopy(mysql, "jdbcUrlWithCredentials"); + expect(mysqlUrl).toBe("jdbc:mysql://db.example.com:3306/appdb?user=app_user&password=p=ss=word"); + expect(parseConnectionUrl(mysqlUrl ?? "").password).toBe(password); + + const oceanbase = config({ db_type: "mysql", port: 2883, driver_profile: "oceanbase", password }); + const oceanbaseUrl = buildConnectionUrlCopy(oceanbase, "jdbcUrlWithCredentials"); + expect(oceanbaseUrl).toBe("jdbc:oceanbase://db.example.com:2883/appdb?user=app_user&password=p=ss=word"); + expect(parseConnectionUrl(oceanbaseUrl ?? "").password).toBe(password); + }); + it("keeps Oracle Easy Connect credentials literal instead of URI-encoding them", () => { const oracle = config({ db_type: "oracle", host: "ora.example.com", port: 1521, database: "ORCLPDB1", password: "p^ss%40#y" }); expect(buildConnectionUrlCopy(oracle, "jdbcUrlWithCredentials")).toBe("jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1?user=app_user&password=p^ss%40#y"); diff --git a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts index 92f4c4bba0..b0ec9cd9f9 100644 --- a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts +++ b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts @@ -124,6 +124,9 @@ const POSTGRES_WIRE_DB_TYPES = new Set(["postgres", "redshift", "g /** PG-wire families whose JDBC driver accepts the `ssl`/`sslmode` properties. */ const PG_JDBC_PREFIXES = new Set(["jdbc:postgresql", "jdbc:redshift", "jdbc:gaussdb", "jdbc:opengauss", "jdbc:kingbase8", "jdbc:highgo"]); +/** MySQL-family prefixes that share Connector/J's `?key=value&...` query grammar. */ +const MYSQL_QUERY_JDBC_PREFIXES = new Set(["jdbc:mysql", "jdbc:mariadb", "jdbc:oceanbase"]); + function normalizedDriverProfile(config: ConnectionUrlCopyConfig): string { return config.driver_profile?.trim().toLowerCase() ?? ""; } @@ -188,9 +191,16 @@ function shouldAppendPort(config: ConnectionUrlCopyConfig): boolean { * RFC 3986 component encoding for credentials. `encodeURIComponent` leaves * `!'()*` unescaped, so encode those too; only unreserved characters remain * literal and every encoded credential can be decoded back byte-for-byte. + * + * `=` is also kept literal: userinfo and query values split on their first + * separator, so an embedded `=` cannot change how the string is read, while + * escaping it made copied passwords differ from the stored one for clients + * that never percent-decode (#12304). */ function encodeCredential(value: string): string { - return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + return encodeURIComponent(value) + .replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%3D/g, "="); } /** @@ -432,13 +442,13 @@ function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string, function jdbcQuery(config: ConnectionUrlCopyConfig, prefix: string, withCredentials: boolean): string { const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, ""); // Strict encoding is enabled only for verified query-style drivers. - const encode = prefix === "jdbc:mysql" || prefix === "jdbc:postgresql" ? encodeCredential : encodeURIComponent; + const encode = MYSQL_QUERY_JDBC_PREFIXES.has(prefix) || prefix === "jdbc:postgresql" ? encodeCredential : encodeURIComponent; const parts = [...jdbcCredentialParams(config, withCredentials, encode)]; if (raw) parts.push(raw); if (config.ssl) { if (PG_JDBC_PREFIXES.has(prefix) && !queryHasParam(raw, ["ssl", "sslmode"])) { parts.push("ssl=true"); - } else if ((prefix === "jdbc:mysql" || prefix === "jdbc:mariadb" || prefix === "jdbc:oceanbase") && !queryHasParam(raw, ["usessl", "sslmode", "requiressl"])) { + } else if (MYSQL_QUERY_JDBC_PREFIXES.has(prefix) && !queryHasParam(raw, ["usessl", "sslmode", "requiressl"])) { parts.push("sslMode=REQUIRED"); } else if (prefix === "jdbc:clickhouse" && !queryHasParam(raw, ["ssl", "sslmode", "secure"])) { parts.push("ssl=true");