From 7d5cae6486706973c2ac13138b783a889fdbccf1 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 21 Aug 2026 19:57:22 +0800 Subject: [PATCH] fix(mysql): preserve JDBC TLS parameter semantics --- Cargo.lock | 2 +- Cargo.toml | 6 +- .../connection/ConnectionDialog.vue | 11 +- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../__tests__/connectionErrorHints.spec.ts | 47 +++++ .../lib/connection/connectionErrorHints.ts | 39 +++- .../src/lib/connection/connectionUrl.ts | 24 ++- crates/dbx-core/src/db/mysql.rs | 132 ++++++++++++- crates/dbx-core/src/models/connection.rs | 173 +++++++++++++++++- packages/app-tests/connectionUrl.test.ts | 9 + 17 files changed, 428 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10e115b0f4..3bda9bf0d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "mysql_async" version = "0.37.0" -source = "git+https://github.com/t8y2/mysql_async.git?rev=b615bb0b10b5975182a4aa069cabc3d09021f6ab#b615bb0b10b5975182a4aa069cabc3d09021f6ab" +source = "git+https://github.com/zipg/mysql_async.git?rev=4a9e60a62f5a48e35ef9ccc9fc8277d5012b19dd#4a9e60a62f5a48e35ef9ccc9fc8277d5012b19dd" dependencies = [ "bytes", "crossbeam-queue", diff --git a/Cargo.toml b/Cargo.toml index eb1b6cff0c..d2ae9675e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,9 +27,9 @@ wry = { path = "vendor/wry" } tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" } postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" } postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "ac8ad476de59093d33ab9f294531c956e0c6a724" } -# Temporary compatibility patch for MySQL accounts using the deprecated -# sha256_password auth plugin. Remove after upstream support lands. -mysql_async = { git = "https://github.com/t8y2/mysql_async.git", rev = "b615bb0b10b5975182a4aa069cabc3d09021f6ab" } +# Temporary compatibility patches for deprecated sha256_password accounts and +# rustls dangerous-mode connections to legacy certificates. Remove after upstream support lands. +mysql_async = { git = "https://github.com/zipg/mysql_async.git", rev = "4a9e60a62f5a48e35ef9ccc9fc8277d5012b19dd" } # Preserve readable SQL Server result sets when a row contains an unpaired # UTF-16 surrogate. Remove after the behavior is available in upstream tiberius. tiberius = { path = "vendor/tiberius" } diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 90332c5210..63736f324f 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -4529,6 +4529,15 @@ function mysqlTlsModeFromParams(params: string | undefined, ssl: boolean | undef return "verify_identity"; } + const jdbcUseSsl = getUrlParam(params, "useSSL").trim().toLowerCase(); + const jdbcRequireSsl = getUrlParam(params, "requireSSL").trim().toLowerCase(); + const jdbcVerifyServerCertificate = getUrlParam(params, "verifyServerCertificate").trim().toLowerCase(); + const isTrue = (value: string) => ["true", "1", "yes", "on"].includes(value); + if (isTrue(jdbcVerifyServerCertificate) && (isTrue(jdbcUseSsl) || isTrue(jdbcRequireSsl))) return "verify_ca"; + if (isTrue(jdbcRequireSsl)) return "required"; + if (["false", "0", "no", "off"].includes(jdbcUseSsl)) return "disabled"; + if (isTrue(jdbcUseSsl)) return "preferred"; + if (!ssl && getUrlParam(params, "require_ssl").toLowerCase() !== "true") return "disabled"; if (getUrlParam(params, "verify_identity").toLowerCase() === "true") return "verify_identity"; if (getUrlParam(params, "verify_ca").toLowerCase() === "true") return "verify_ca"; @@ -4536,7 +4545,7 @@ function mysqlTlsModeFromParams(params: string | undefined, ssl: boolean | undef } function applyMysqlTlsMode(params: string | undefined, mode: string): string { - let next = deleteUrlParams(params, ["ssl-mode", "sslmode", "require_ssl", "verify_ca", "verify_identity"]); + let next = deleteUrlParams(params, ["ssl-mode", "sslmode", "sslMode", "require_ssl", "verify_ca", "verify_identity", "useSSL", "requireSSL", "verifyServerCertificate"]); if (mode === "disabled") { return setUrlParam(next, "ssl-mode", "disabled"); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index cb9b8c1855..2d41f878ef 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -574,6 +574,7 @@ export default { mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "Client certificate and private key must be provided together when MySQL requires mTLS.", mysqlTlsConnectionFailureHint: "If the MySQL server does not require TLS, edit the connection, set TLS Mode to Disabled, and try again.", + mysqlUnsupportedCertVersionHint: "Strict verification does not support this server certificate version. Replace it with an X.509 v3 certificate, or use Required mode only on a trusted network.", mysqlClientCertBrowse: "Choose client certificate", mysqlClientKeyBrowse: "Choose client private key", postgresSslMode: "TLS Mode", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7efd1b04ad..6733789872 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -549,6 +549,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/ruta/a/client.key", mysqlClientCertHint: "El certificado y la clave privada deben indicarse juntos si MySQL requiere mTLS.", mysqlTlsConnectionFailureHint: "Si el servidor MySQL no requiere TLS, edita la conexión, establece el Modo TLS en Deshabilitado e inténtalo de nuevo.", + mysqlUnsupportedCertVersionHint: "La verificación estricta no admite esta versión del certificado del servidor. Sustitúyelo por un certificado X.509 v3 o usa el modo Requerido solo en una red de confianza.", mysqlClientCertBrowse: "Elegir certificado cliente", mysqlClientKeyBrowse: "Elegir clave privada cliente", postgresSslMode: "Modo TLS", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 993599959b..e0c4750665 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -548,6 +548,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/percorso/per/client.key", mysqlClientCertHint: "Il certificato client e la chiave privata devono essere forniti insieme quando MySQL richiede mTLS.", mysqlTlsConnectionFailureHint: "Se il server MySQL non richiede TLS, modifica la connessione, imposta la modalità TLS su Disabilitata e riprova.", + mysqlUnsupportedCertVersionHint: "La verifica rigorosa non supporta questa versione del certificato del server. Sostituiscilo con un certificato X.509 v3 oppure usa la modalità Obbligatoria solo su una rete attendibile.", mysqlClientCertBrowse: "Scegli certificato client", mysqlClientKeyBrowse: "Scegli chiave privata client", postgresSslMode: "Modalità TLS", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6ce6d52963..59f570a286 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -548,6 +548,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQLがmTLSを要求する場合、クライアント証明書と秘密鍵の両方を指定する必要があります。", mysqlTlsConnectionFailureHint: "MySQLサーバーがTLSを要求しない場合は、接続を編集し、TLSモードを無効にしてから再試行してください。", + mysqlUnsupportedCertVersionHint: "厳密な検証では、このサーバー証明書のバージョンはサポートされていません。X.509 v3証明書に置き換えるか、信頼できるネットワークでのみ「必須」モードを使用してください。", mysqlClientCertBrowse: "クライアント証明書を選択", mysqlClientKeyBrowse: "クライアント秘密鍵を選択", postgresSslMode: "TLSモード", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 0b1bf1c1ca..2852838a86 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -568,6 +568,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQL이 mTLS를 요구할 때 클라이언트 인증서와 개인 키를 함께 제공해야 합니다.", mysqlTlsConnectionFailureHint: "MySQL 서버가 TLS를 요구하지 않으면 연결을 편집하여 TLS 모드를 비활성화로 설정하고 다시 시도하세요.", + mysqlUnsupportedCertVersionHint: "엄격한 검증은 이 서버 인증서 버전을 지원하지 않습니다. X.509 v3 인증서로 교체하거나 신뢰할 수 있는 네트워크에서만 필수 모드를 사용하세요.", mysqlClientCertBrowse: "클라이언트 인증서 선택", mysqlClientKeyBrowse: "클라이언트 개인 키 선택", postgresSslMode: "TLS 모드", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bdcd9146e4..f76af49764 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -549,6 +549,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/caminho/para/client.key", mysqlClientCertHint: "O certificado e a chave privada do cliente devem ser fornecidos juntos quando o MySQL exige mTLS.", mysqlTlsConnectionFailureHint: "Se o servidor MySQL não exigir TLS, edite a conexão, defina o Modo TLS como Desabilitado e tente novamente.", + mysqlUnsupportedCertVersionHint: "A verificação estrita não oferece suporte a esta versão do certificado do servidor. Substitua-o por um certificado X.509 v3 ou use o modo Obrigatório apenas em uma rede confiável.", mysqlClientCertBrowse: "Escolher certificado do cliente", mysqlClientKeyBrowse: "Escolher chave privada do cliente", postgresSslMode: "Modo TLS", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bfa2a85b2f..4f0cbbf40f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -499,6 +499,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQL 要求 mTLS 时,客户端证书和私钥必须一起填写。", mysqlTlsConnectionFailureHint: "如果 MySQL 服务端不要求 TLS,请编辑连接,在 TLS 模式中选择“禁用”后重试。", + mysqlUnsupportedCertVersionHint: "服务端证书版本不受严格校验支持。请更换为 X.509 v3 证书,或仅在可信网络中改用“必需”模式。", mysqlClientCertBrowse: "选择客户端证书", mysqlClientKeyBrowse: "选择客户端私钥", postgresSslMode: "TLS 模式", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c938fd6d96..3408f66938 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -549,6 +549,7 @@ export default withEnglishFallback({ mysqlClientKeyPlaceholder: "/path/to/client.key", mysqlClientCertHint: "MySQL 要求 mTLS 時,用戶端憑證和私鑰必須一起填寫。", mysqlTlsConnectionFailureHint: "如果 MySQL 伺服器不要求 TLS,請編輯連線,在 TLS 模式中選擇「停用」後重試。", + mysqlUnsupportedCertVersionHint: "伺服器憑證版本不受嚴格驗證支援。請更換為 X.509 v3 憑證,或僅在可信任網路中改用「必要」模式。", mysqlClientCertBrowse: "選擇用戶端憑證", mysqlClientKeyBrowse: "選擇用戶端私鑰", postgresSslMode: "TLS 模式", diff --git a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts index 8e416c6e29..28d3d19d93 100644 --- a/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts +++ b/apps/desktop/src/lib/connection/__tests__/connectionErrorHints.spec.ts @@ -33,6 +33,7 @@ function jdbcConfig(): ConnectionConfig { const t = (key: string) => { if (key === "connection.mysqlTlsConnectionFailureHint") return "Set TLS Mode to Disabled."; + if (key === "connection.mysqlUnsupportedCertVersionHint") return "Replace the certificate or use Required mode."; if (key === "connection.mysqlMissingPasswordHint") return "No database password was sent."; if (key === "connection.jdbcMissingRuntimeDependencyHint") return "Install from Maven or import every dependency JAR."; return key; @@ -53,6 +54,52 @@ describe("appendConnectionErrorHints", () => { expect(message).toContain("Set TLS Mode to Disabled."); }); + it("recognizes Connector/J TLS aliases", () => { + const message = appendConnectionErrorHints(mysqlConfig("useSSL=true&requireSSL=true&verifyServerCertificate=true"), "MySQL connection failed: TLS handshake failed", t); + + expect(message).toContain("Set TLS Mode to Disabled."); + }); + + it("treats verifyServerCertificate as verified TLS unless useSSL is disabled", () => { + const verified = appendConnectionErrorHints(mysqlConfig("verifyServerCertificate=true"), "MySQL connection failed: TLS handshake failed", t); + const disabled = appendConnectionErrorHints(mysqlConfig("useSSL=false&requireSSL=true&verifyServerCertificate=true"), "MySQL connection failed: TLS handshake failed", t); + + expect(verified).toContain("Set TLS Mode to Disabled."); + expect(disabled).toBe("MySQL connection failed: TLS handshake failed"); + }); + + it("uses the last duplicate Connector/J TLS parameter", () => { + const enabled = appendConnectionErrorHints(mysqlConfig("useSSL=false&useSSL=true"), "MySQL connection failed: TLS handshake failed", t); + const disabled = appendConnectionErrorHints(mysqlConfig("useSSL=true&useSSL=false"), "MySQL connection failed: TLS handshake failed", t); + + expect(enabled).toContain("Set TLS Mode to Disabled."); + expect(disabled).toBe("MySQL connection failed: TLS handshake failed"); + }); + + it("prefers native require_ssl over Connector/J TLS aliases", () => { + const disabled = appendConnectionErrorHints(mysqlConfig("require_ssl=false&verifyServerCertificate=true"), "MySQL connection failed: TLS handshake failed", t); + const required = appendConnectionErrorHints(mysqlConfig("require_ssl=true&useSSL=false"), "MySQL connection failed: TLS handshake failed", t); + + expect(disabled).toBe("MySQL connection failed: TLS handshake failed"); + expect(required).toContain("Set TLS Mode to Disabled."); + }); + + it("uses the last duplicate native require_ssl value", () => { + const required = appendConnectionErrorHints(mysqlConfig("require_ssl=false&require_ssl=true"), "MySQL connection failed: TLS handshake failed", t); + const disabled = appendConnectionErrorHints(mysqlConfig("require_ssl=true&require_ssl=false"), "MySQL connection failed: TLS handshake failed", t); + + expect(required).toContain("Set TLS Mode to Disabled."); + expect(disabled).toBe("MySQL connection failed: TLS handshake failed"); + }); + + it("uses a certificate-specific hint for unsupported certificate versions", () => { + const error = "MySQL connection failed: invalid peer certificate: Other(OtherError(UnsupportedCertVersion))"; + const message = appendConnectionErrorHints(mysqlConfig("sslMode=VERIFY_CA"), error, t); + + expect(message).toContain("Replace the certificate or use Required mode."); + expect(message).not.toContain("Set TLS Mode to Disabled."); + }); + it("does not add the TLS hint when MySQL TLS is disabled", () => { const message = appendConnectionErrorHints(mysqlConfig("ssl-mode=disabled"), "MySQL connection failed: TLS handshake failed", t); diff --git a/apps/desktop/src/lib/connection/connectionErrorHints.ts b/apps/desktop/src/lib/connection/connectionErrorHints.ts index 2192b6d147..2a546235b9 100644 --- a/apps/desktop/src/lib/connection/connectionErrorHints.ts +++ b/apps/desktop/src/lib/connection/connectionErrorHints.ts @@ -12,10 +12,28 @@ function normalizeUrlParamKey(key: string): string { function urlParamValue(params: URLSearchParams, key: string): string { const normalizedKey = normalizeUrlParamKey(key); + let result = ""; for (const [paramKey, value] of params.entries()) { - if (normalizeUrlParamKey(paramKey) === normalizedKey) return value; + if (normalizeUrlParamKey(paramKey) === normalizedKey) result = value; } - return ""; + return result; +} + +function jdbcUrlParamValue(params: URLSearchParams, key: string): string { + const normalizedKey = key.trim().toLowerCase(); + let result = ""; + for (const [paramKey, value] of params.entries()) { + if (paramKey.trim().toLowerCase() === normalizedKey) result = value; + } + return result; +} + +function hasJdbcUrlParam(params: URLSearchParams, key: string): boolean { + const normalizedKey = key.trim().toLowerCase(); + for (const paramKey of params.keys()) { + if (paramKey.trim().toLowerCase() === normalizedKey) return true; + } + return false; } function mysqlTlsMode(config: ConnectionConfig): string { @@ -25,7 +43,19 @@ function mysqlTlsMode(config: ConnectionConfig): string { if (["disabled", "disable"].includes(mode)) return "disabled"; if (["preferred", "prefer"].includes(mode)) return "preferred"; if (["required", "require", "verify_ca", "verify_identity"].includes(mode)) return mode; - if (config.ssl || ["true", "1", "yes", "on"].includes(urlParamValue(parsed, "require_ssl").trim().toLowerCase())) return "required"; + const isTrue = (value: string) => ["true", "1", "yes", "on"].includes(value); + const isFalse = (value: string) => ["false", "0", "no", "off"].includes(value); + const nativeRequireSsl = jdbcUrlParamValue(parsed, "require_ssl").trim().toLowerCase(); + if (isFalse(nativeRequireSsl)) return "disabled"; + if (isTrue(nativeRequireSsl)) return "required"; + const jdbcUseSsl = jdbcUrlParamValue(parsed, "useSSL").trim().toLowerCase(); + const jdbcRequireSsl = jdbcUrlParamValue(parsed, "requireSSL").trim().toLowerCase(); + const jdbcVerifyServerCertificate = jdbcUrlParamValue(parsed, "verifyServerCertificate").trim().toLowerCase(); + if (isFalse(jdbcUseSsl)) return "disabled"; + if (isTrue(jdbcVerifyServerCertificate)) return "verify_ca"; + if (isTrue(jdbcRequireSsl)) return "required"; + if (hasJdbcUrlParam(parsed, "useSSL") || hasJdbcUrlParam(parsed, "requireSSL") || hasJdbcUrlParam(parsed, "verifyServerCertificate")) return "preferred"; + if (config.ssl) return "required"; return "disabled"; } @@ -66,6 +96,9 @@ export function appendConnectionErrorHints(config: ConnectionConfig | undefined, } if (mysqlTlsMode(config) === "disabled") return message; if (!isMysqlTlsLikeFailure(message)) return message; + if (/UnsupportedCertVersion/i.test(message)) { + return appendHint(message, t("connection.mysqlUnsupportedCertVersionHint")); + } const hint = t("connection.mysqlTlsConnectionFailureHint"); return appendHint(message, hint); } diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index dee2d5712c..97f58184d3 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -257,6 +257,18 @@ function queryParamValue(params: string, key: string): string | undefined { return undefined; } +function queryParamLastValue(params: string, key: string): string | undefined { + let result: string | undefined; + for (const part of params.split(/[&;]/)) { + if (!part) continue; + const [rawKey, ...rest] = part.split("="); + if (decodeUrlPart(rawKey).toLowerCase() === key.toLowerCase()) { + result = decodeUrlPart(rest.join("=")).trim(); + } + } + return result; +} + function extractHiveStructuredParams(params: string): { username?: string; password?: string; ssl: boolean; urlParams: string } { let username: string | undefined; let password: string | undefined; @@ -334,10 +346,16 @@ function urlParamsRequireTls(dbType: DatabaseType, params: string): boolean { } if (dbType === "mysql") { - const requireSsl = queryParamValue(params, "require_ssl")?.toLowerCase(); + const requireSsl = queryParamLastValue(params, "require_ssl")?.toLowerCase(); if (requireSsl === "true" || requireSsl === "1" || requireSsl === "yes") return true; - const sslMode = (queryParamValue(params, "ssl-mode") || queryParamValue(params, "sslmode") || "").toLowerCase().replace("-", "_"); - return sslMode === "required" || sslMode === "require" || sslMode === "verify_ca" || sslMode === "verify_identity"; + const sslMode = (queryParamLastValue(params, "ssl-mode") || queryParamLastValue(params, "sslmode") || "").toLowerCase().replace("-", "_"); + if (sslMode === "required" || sslMode === "require" || sslMode === "verify_ca" || sslMode === "verify_identity") return true; + if (requireSsl !== undefined || sslMode) return false; + const jdbcUseSsl = (queryParamLastValue(params, "useSSL") || "").toLowerCase(); + const jdbcRequireSsl = (queryParamLastValue(params, "requireSSL") || "").toLowerCase(); + const jdbcVerifyServerCertificate = (queryParamLastValue(params, "verifyServerCertificate") || "").toLowerCase(); + if (["false", "0", "no", "off"].includes(jdbcUseSsl)) return false; + return ["true", "1", "yes", "on"].includes(jdbcRequireSsl) || ["true", "1", "yes", "on"].includes(jdbcVerifyServerCertificate); } if (dbType === "postgres" || dbType === "redshift" || dbType === "kwdb") { diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 961eb059dc..da15b10bea 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -18,7 +18,10 @@ use std::time::Duration; use std::time::Instant; use tokio_util::sync::CancellationToken; -use crate::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType}; +use crate::models::connection::{ + is_mysql_jdbc_tls_param, mysql_jdbc_tls_mode, ConnectionConfig, DatabaseConnectionInfo, DatabaseType, + MysqlJdbcTlsMode, +}; use crate::schema::{table_name_filter_matches, TableNameFilter}; use crate::sql::{starts_with_executable_sql_keyword, starts_with_executable_sql_keyword_for_database}; use crate::types::{ @@ -1914,7 +1917,14 @@ fn mysql_url_requires_ssl(url: &str) -> bool { let Some((_, query)) = url.split_once('?') else { return false; }; - query.split('&').any(|segment| { + let query = query.split('#').next().unwrap_or(query); + let has_native_tls_param = query.split('&').any(|segment| { + let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim(); + key.eq_ignore_ascii_case("ssl-mode") + || key.eq_ignore_ascii_case("sslmode") + || key.eq_ignore_ascii_case("require_ssl") + }); + let native_requires_ssl = query.split('&').any(|segment| { let Some((key, value)) = segment.split_once('=') else { return false; }; @@ -1928,7 +1938,13 @@ fn mysql_url_requires_ssl(url: &str) -> bool { value.to_ascii_lowercase().replace('-', "_").as_str(), "required" | "require" | "verify_ca" | "verify_identity" )) - }) + }); + native_requires_ssl + || (!has_native_tls_param + && matches!( + mysql_jdbc_tls_mode(Some(query)), + Some(MysqlJdbcTlsMode::Required | MysqlJdbcTlsMode::VerifyCa) + )) } fn mysql_url_attempts_ssl(url: &str) -> bool { @@ -1939,7 +1955,14 @@ fn mysql_url_attempts_ssl(url: &str) -> bool { let Some((_, query)) = url.split_once('?') else { return false; }; - query.split('&').any(|segment| { + let query = query.split('#').next().unwrap_or(query); + let has_native_tls_param = query.split('&').any(|segment| { + let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim(); + key.eq_ignore_ascii_case("ssl-mode") + || key.eq_ignore_ascii_case("sslmode") + || key.eq_ignore_ascii_case("require_ssl") + }); + let native_attempts_ssl = query.split('&').any(|segment| { let Some((key, value)) = segment.split_once('=') else { return false; }; @@ -1947,7 +1970,13 @@ fn mysql_url_attempts_ssl(url: &str) -> bool { let value = value.trim(); (key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) && matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "preferred" | "prefer") - }) + }); + native_attempts_ssl + || (!has_native_tls_param + && matches!( + mysql_jdbc_tls_mode(Some(query)), + Some(MysqlJdbcTlsMode::Preferred | MysqlJdbcTlsMode::Required | MysqlJdbcTlsMode::VerifyCa) + )) } fn mysql_url_verifies_identity(url: &str) -> bool { @@ -2069,6 +2098,13 @@ fn mysql_async_url(url: &str) -> Cow<'_, str> { let mut changed = false; let mut has_catalog = false; let mut enable_cleartext_plugin = false; + let has_native_tls_param = query.split('&').any(|segment| { + let key = segment.split_once('=').map(|(key, _)| key).unwrap_or(segment).trim(); + key.eq_ignore_ascii_case("ssl-mode") + || key.eq_ignore_ascii_case("sslmode") + || key.eq_ignore_ascii_case("require_ssl") + }); + let jdbc_tls_mode = mysql_jdbc_tls_mode(Some(query)); for segment in query.split('&') { let segment = segment.trim(); if segment.is_empty() { @@ -2088,6 +2124,10 @@ fn mysql_async_url(url: &str) -> Cow<'_, str> { enable_cleartext_plugin |= mysql_url_param_value_is_true(value); continue; } + if is_mysql_jdbc_tls_param(key) { + changed = true; + continue; + } if is_dbx_handled_mysql_url_param(key) { changed = true; continue; @@ -2121,6 +2161,22 @@ fn mysql_async_url(url: &str) -> Cow<'_, str> { } filtered.push(segment.to_string()); } + if !has_native_tls_param { + match jdbc_tls_mode { + Some(MysqlJdbcTlsMode::Disabled) => filtered.push("require_ssl=false".to_string()), + Some(MysqlJdbcTlsMode::Preferred | MysqlJdbcTlsMode::Required) => { + filtered.push("require_ssl=true".to_string()); + filtered.push("verify_ca=false".to_string()); + filtered.push("verify_identity=false".to_string()); + } + Some(MysqlJdbcTlsMode::VerifyCa) => { + filtered.push("require_ssl=true".to_string()); + filtered.push("verify_ca=true".to_string()); + filtered.push("verify_identity=false".to_string()); + } + None => {} + } + } if enable_cleartext_plugin { filtered.push("enable_cleartext_plugin=true".to_string()); } @@ -7619,9 +7675,58 @@ mod tests { } #[test] - fn mysql_async_url_strips_jdbc_params() { + fn mysql_async_url_translates_connector_j_tls_params() { let url = "mysql://host:3306/db?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true"; - assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db"); + assert_eq!( + mysql_async_url(url).as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false" + ); + } + + #[test] + fn mysql_async_url_translates_connector_j_required_verified_tls_params() { + let url = "mysql://host:3306/db?useSSL=true&requireSSL=true&verifyServerCertificate=true"; + assert_eq!( + mysql_async_url(url).as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=true&verify_identity=false" + ); + } + + #[test] + fn mysql_async_url_preserves_connector_j_tls_truth_table() { + assert_eq!( + mysql_async_url("mysql://host:3306/db?verifyServerCertificate=true").as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=true&verify_identity=false" + ); + assert_eq!( + mysql_async_url("mysql://host:3306/db?useSSL=false&requireSSL=true&verifyServerCertificate=true").as_ref(), + "mysql://host:3306/db?require_ssl=false" + ); + assert_eq!( + mysql_async_url("mysql://host:3306/db?requireSSL=false").as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false" + ); + } + + #[test] + fn mysql_async_url_uses_last_connector_j_tls_param_value() { + assert_eq!( + mysql_async_url("mysql://host:3306/db?useSSL=false&useSSL=true").as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false" + ); + assert_eq!( + mysql_async_url("mysql://host:3306/db?useSSL=true&useSSL=false&verifyServerCertificate=true").as_ref(), + "mysql://host:3306/db?require_ssl=false" + ); + } + + #[test] + fn mysql_async_url_prefers_native_tls_mode_over_connector_j_aliases() { + let url = "mysql://host:3306/db?sslMode=REQUIRED&useSSL=false&requireSSL=false"; + assert_eq!( + mysql_async_url(url).as_ref(), + "mysql://host:3306/db?require_ssl=true&verify_ca=false&verify_identity=false" + ); } #[test] @@ -7630,6 +7735,19 @@ mod tests { assert_eq!(mysql_async_url(url).as_ref(), "mysql://host:3306/db?require_ssl=true"); } + #[test] + fn connector_j_preferred_tls_falls_back_but_required_tls_does_not() { + assert_eq!( + ssl_fallback_url("mysql://host:3306/db?useSSL=true&characterEncoding=utf8"), + Some("mysql://host:3306/db?useSSL=true&characterEncoding=utf8&ssl-mode=disabled".to_string()) + ); + assert_eq!(ssl_fallback_url("mysql://host:3306/db?useSSL=true&requireSSL=true"), None); + assert_eq!(ssl_fallback_url("mysql://host:3306/db?verifyServerCertificate=true"), None); + let disabled = "mysql://host:3306/db?useSSL=false&requireSSL=true&verifyServerCertificate=true"; + assert!(!mysql_url_requires_ssl(disabled)); + assert!(!mysql_url_attempts_ssl(disabled)); + } + #[test] fn mysql_async_url_accepts_reported_doris_jdbc_params() { let url = "mysql://host:9030/db?useLocalSessionState=true&rewriteBatchedStatements=true&prepStmtCacheSqlLimit=2048&prepStmtCacheSize=250&sessionVariables=query_timeout%3D60"; diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index f40c39b41d..2594bdb119 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -1738,7 +1738,11 @@ fn mysql_tls_file_param_is(key: &str, target: &str) -> bool { } fn mysql_url_params_tls_disabled(params: Option<&str>) -> bool { - params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| { + let params = params.unwrap_or("").trim().trim_start_matches('?'); + let has_native_tls_param = params.split('&').any(|part| { + url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl") + }); + let native_tls_disabled = params.split('&').any(|part| { let part = part.trim(); if part.is_empty() { return false; @@ -1751,11 +1755,17 @@ fn mysql_url_params_tls_disabled(params: Option<&str>) -> bool { (key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("false")) || ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) && matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "disabled" | "disable")) - }) + }); + native_tls_disabled + || (!has_native_tls_param && mysql_jdbc_tls_mode(Some(params)) == Some(MysqlJdbcTlsMode::Disabled)) } fn mysql_url_params_require_tls(params: Option<&str>) -> bool { - params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| { + let params = params.unwrap_or("").trim().trim_start_matches('?'); + let has_native_tls_param = params.split('&').any(|part| { + url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl") + }); + let native_requires_tls = params.split('&').any(|part| { let part = part.trim(); if part.is_empty() { return false; @@ -1773,7 +1783,71 @@ fn mysql_url_params_require_tls(params: Option<&str>) -> bool { value.to_ascii_lowercase().replace('-', "_").as_str(), "required" | "require" | "verify_ca" | "verify_identity" )) - }) + }); + native_requires_tls + || (!has_native_tls_param + && matches!( + mysql_jdbc_tls_mode(Some(params)), + Some(MysqlJdbcTlsMode::Required | MysqlJdbcTlsMode::VerifyCa) + )) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MysqlJdbcTlsMode { + Disabled, + Preferred, + Required, + VerifyCa, +} + +pub(crate) fn is_mysql_jdbc_tls_param(key: &str) -> bool { + matches!( + percent_decode_str(key).decode_utf8_lossy().trim().to_ascii_lowercase().as_str(), + "usessl" | "requiressl" | "verifyservercertificate" + ) +} + +pub(crate) fn mysql_jdbc_tls_mode(params: Option<&str>) -> Option { + let mut has_jdbc_tls_param = false; + let mut use_ssl = None; + let mut require_ssl = None; + let mut verify_server_certificate = None; + + for part in params.unwrap_or("").trim().trim_start_matches('?').split('&') { + let Some((raw_key, raw_value)) = part.split_once('=') else { + continue; + }; + let key = percent_decode_str(raw_key).decode_utf8_lossy().trim().to_ascii_lowercase(); + let value = percent_decode_str(raw_value).decode_utf8_lossy(); + let parsed_value = mysql_url_param_value_is_true(&value); + match key.as_str() { + "usessl" => { + has_jdbc_tls_param = true; + use_ssl = Some(parsed_value); + } + "requiressl" => { + has_jdbc_tls_param = true; + require_ssl = Some(parsed_value); + } + "verifyservercertificate" => { + has_jdbc_tls_param = true; + verify_server_certificate = Some(parsed_value); + } + _ => {} + } + } + + if !has_jdbc_tls_param { + None + } else if use_ssl == Some(false) { + Some(MysqlJdbcTlsMode::Disabled) + } else if verify_server_certificate == Some(true) { + Some(MysqlJdbcTlsMode::VerifyCa) + } else if require_ssl == Some(true) { + Some(MysqlJdbcTlsMode::Required) + } else { + Some(MysqlJdbcTlsMode::Preferred) + } } fn normalize_bare_mysql_url_params(value: &str) -> String { @@ -1804,6 +1878,10 @@ fn mysql_url_param_value_is_true(value: &str) -> bool { fn normalize_mysql_url_params(value: &str, force_tls: bool, accept_invalid_certs: bool) -> String { let value = value.trim_start_matches('?'); let mut parts: Vec = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect(); + let jdbc_tls_mode = mysql_jdbc_tls_mode(Some(value)); + let has_native_tls_param = parts.iter().any(|part| { + url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl") + }); let enable_cleartext_plugin = parts.iter().any(|part| { let Some((key, value)) = part.split_once('=') else { return false; @@ -1815,9 +1893,27 @@ fn normalize_mysql_url_params(value: &str, force_tls: bool, accept_invalid_certs let Some((key, _)) = part.split_once('=') else { return true; }; - !is_mysql_cleartext_password_param(key.trim()) + !is_mysql_cleartext_password_param(key.trim()) && !is_mysql_jdbc_tls_param(key.trim()) }); + if !has_native_tls_param { + match jdbc_tls_mode { + Some(MysqlJdbcTlsMode::Disabled) => parts.push("ssl-mode=disabled".to_string()), + Some(MysqlJdbcTlsMode::Preferred) => parts.push("ssl-mode=preferred".to_string()), + Some(MysqlJdbcTlsMode::Required) => { + parts.push("require_ssl=true".to_string()); + parts.push("verify_ca=false".to_string()); + parts.push("verify_identity=false".to_string()); + } + Some(MysqlJdbcTlsMode::VerifyCa) => { + parts.push("require_ssl=true".to_string()); + parts.push("verify_ca=true".to_string()); + parts.push("verify_identity=false".to_string()); + } + None => {} + } + } + if force_tls { parts.retain(|part| { !url_param_key_is(part, "ssl-mode") @@ -3467,6 +3563,73 @@ mod tests { ); } + #[test] + fn mysql_connector_j_tls_params_are_normalized() { + let mut config = mysql_config("root", "secret", Some("test")); + config.url_params = Some("useSSL=true&requireSSL=true&verifyServerCertificate=true".to_string()); + + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4" + ); + } + + #[test] + fn mysql_connector_j_preferred_and_disabled_tls_modes_are_preserved() { + let mut config = mysql_config("root", "secret", Some("test")); + config.url_params = Some("useSSL=true".to_string()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4" + ); + + config.url_params = Some("useSSL=false".to_string()); + assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=disabled&charset=utf8mb4"); + } + + #[test] + fn mysql_connector_j_tls_truth_table_preserves_legacy_precedence() { + let mut config = mysql_config("root", "secret", Some("test")); + config.url_params = Some("verifyServerCertificate=true".to_string()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4" + ); + + config.url_params = Some("useSSL=false&requireSSL=true&verifyServerCertificate=true".to_string()); + assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=disabled&charset=utf8mb4"); + + config.url_params = Some("requireSSL=false".to_string()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4" + ); + } + + #[test] + fn mysql_connector_j_duplicate_tls_params_use_the_last_value() { + let mut config = mysql_config("root", "secret", Some("test")); + config.url_params = Some("useSSL=false&useSSL=true".to_string()); + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4" + ); + + config.url_params = Some("useSSL=true&useSSL=false&verifyServerCertificate=true".to_string()); + assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=disabled&charset=utf8mb4"); + } + + #[test] + fn mysql_native_tls_mode_takes_precedence_over_connector_j_aliases() { + let mut config = mysql_config("root", "secret", Some("test")); + config.url_params = Some("sslMode=REQUIRED&useSSL=false".to_string()); + + assert_eq!( + config.connection_url(), + "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4" + ); + } + #[test] fn mysql_tls_switch_uses_ca_cert_for_ca_validation() { let mut config = mysql_config("root", "secret", Some("test")); diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index 9c5bc794c8..43626aa8b5 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -162,6 +162,15 @@ test("removes only the connection name from URL params", () => { test("parses mysql TLS URL params into the SSL switch state", () => { assert.equal(parseConnectionUrl("mysql://root@tidb.example.com:4000/test?ssl-mode=required").ssl, true); assert.equal(parseConnectionUrl("mysql://root@tidb.example.com:4000/test?require_ssl=true").ssl, true); + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?useSSL=true&requireSSL=true&verifyServerCertificate=true").ssl, true); + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?verifyServerCertificate=true").ssl, true); + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?useSSL=false").ssl, false); + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?useSSL=false&requireSSL=true&verifyServerCertificate=true").ssl, false); +}); + +test("uses the last duplicate MySQL JDBC TLS parameter", () => { + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?useSSL=false&useSSL=true&requireSSL=true").ssl, true); + assert.equal(parseConnectionUrl("jdbc:mysql://db.example.com/test?useSSL=true&useSSL=false&requireSSL=true").ssl, false); }); test("parses TiDB Cloud MySQL URLs as TLS connections", () => {