From e9a2c08a46176412545f05ae027b4d2201afcb42 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 27 May 2026 13:54:32 +0800 Subject: [PATCH] feat(db): support TLS client certificates --- Cargo.lock | 1 + .../connection/ConnectionDialog.vue | 428 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 31 ++ apps/desktop/src/i18n/locales/es.ts | 33 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 29 ++ crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/db/mysql.rs | 173 ++++++- crates/dbx-core/src/db/postgres.rs | 326 +++++++++++-- crates/dbx-core/tests/live_mtls.rs | 24 + .../app-tests/connectionDialogTls.test.ts | 33 +- 10 files changed, 1033 insertions(+), 46 deletions(-) create mode 100644 crates/dbx-core/tests/live_mtls.rs diff --git a/Cargo.lock b/Cargo.lock index 4325cceb99..7e98a8574e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "russh", "rust_decimal", "rustls 0.23.40", + "rustls-pemfile 2.2.0", "serde", "serde_json", "sha2 0.10.9", diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 4d998ccaa3..48eb3941ef 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -29,9 +29,11 @@ import { FilePlus2, FolderOpen, Grid3X3, + KeyRound, Link2, List, Search, + ShieldCheck, } from "lucide-vue-next"; type DbOption = { value: string; label: string }; @@ -573,7 +575,63 @@ const tlsCapableDatabaseTypes = new Set([ "elasticsearch", ]); const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type)); -const supportsCaCertificatePath = computed(() => form.value.db_type === "mysql" || form.value.db_type === "clickhouse"); +const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse"); +const bareMysqlProfiles = new Set(["doris", "starrocks", "selectdb", "oceanbase"]); +const supportsMysqlTlsOptions = computed( + () => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value), +); +const mysqlTlsMode = computed({ + get: () => mysqlTlsModeFromParams(form.value.url_params, form.value.ssl), + set: (value: string) => { + form.value.ssl = value !== "preferred" && value !== "disabled"; + form.value.url_params = applyMysqlTlsMode(form.value.url_params, value); + }, +}); +const mysqlClientCertPath = computed({ + get: () => getUrlParam(form.value.url_params, "ssl-cert") || getUrlParam(form.value.url_params, "sslcert"), + set: (value: string) => { + let next = setUrlParam(form.value.url_params, "sslcert", ""); + form.value.url_params = setUrlParam(next, "ssl-cert", value); + }, +}); +const mysqlClientKeyPath = computed({ + get: () => getUrlParam(form.value.url_params, "ssl-key") || getUrlParam(form.value.url_params, "sslkey"), + set: (value: string) => { + let next = setUrlParam(form.value.url_params, "sslkey", ""); + form.value.url_params = setUrlParam(next, "ssl-key", value); + }, +}); +const nativePostgresTlsDatabaseTypes = new Set(["postgres", "redshift", "gaussdb", "opengauss"]); +const supportsPostgresTlsOptions = computed(() => nativePostgresTlsDatabaseTypes.has(form.value.db_type)); +const postgresTlsMode = computed({ + get: () => { + const value = normalizePostgresSslMode(getUrlParam(form.value.url_params, "sslmode")); + if (value) return value; + return form.value.ssl ? "require" : "disable"; + }, + set: (value: string) => { + form.value.ssl = value !== "disable"; + form.value.url_params = setUrlParam(form.value.url_params, "sslmode", value === "prefer" ? "" : value); + }, +}); +const postgresRootCertPath = computed({ + get: () => getUrlParam(form.value.url_params, "sslrootcert"), + set: (value: string) => { + form.value.url_params = setUrlParam(form.value.url_params, "sslrootcert", value); + }, +}); +const postgresClientCertPath = computed({ + get: () => getUrlParam(form.value.url_params, "sslcert"), + set: (value: string) => { + form.value.url_params = setUrlParam(form.value.url_params, "sslcert", value); + }, +}); +const postgresClientKeyPath = computed({ + get: () => getUrlParam(form.value.url_params, "sslkey"), + set: (value: string) => { + form.value.url_params = setUrlParam(form.value.url_params, "sslkey", value); + }, +}); const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access"); const canUseProxy = computed( () => form.value.db_type !== "sqlite" && form.value.db_type !== "duckdb" && form.value.db_type !== "access", @@ -712,7 +770,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { config.redis_sentinel_password = undefined; config.redis_sentinel_tls = undefined; } - if (config.db_type !== "clickhouse") { + if (config.db_type !== "mysql" && config.db_type !== "clickhouse") { config.ca_cert_path = undefined; } else { config.ca_cert_path = config.ca_cert_path?.trim() || ""; @@ -730,6 +788,92 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { return config; } +function getUrlParam(params: string | undefined, key: string): string { + const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, "")); + return parsed.get(key) || ""; +} + +function setUrlParam(params: string | undefined, key: string, value: string): string { + const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, "")); + const normalized = value.trim(); + if (normalized) { + parsed.set(key, normalized); + } else { + parsed.delete(key); + } + return parsed.toString(); +} + +function deleteUrlParams(params: string | undefined, keys: string[]): string { + const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, "")); + for (const key of keys) { + parsed.delete(key); + } + return parsed.toString(); +} + +function mysqlTlsModeFromParams(params: string | undefined, ssl: boolean | undefined): string { + const sslMode = getUrlParam(params, "ssl-mode") || getUrlParam(params, "sslmode"); + switch (sslMode.trim().toLowerCase().replace("-", "_")) { + case "disabled": + case "disable": + return "disabled"; + case "preferred": + case "prefer": + return "preferred"; + case "required": + case "require": + return "required"; + case "verify_ca": + return "verify_ca"; + case "verify_identity": + return "verify_identity"; + } + + if (!ssl && getUrlParam(params, "require_ssl").toLowerCase() !== "true") return "preferred"; + if (getUrlParam(params, "verify_identity").toLowerCase() === "true") return "verify_identity"; + if (getUrlParam(params, "verify_ca").toLowerCase() === "true") return "verify_ca"; + return "required"; +} + +function applyMysqlTlsMode(params: string | undefined, mode: string): string { + let next = deleteUrlParams(params, ["ssl-mode", "sslmode", "require_ssl", "verify_ca", "verify_identity"]); + if (mode === "disabled") { + return setUrlParam(next, "ssl-mode", "disabled"); + } + if (mode === "preferred") { + return next; + } + + next = setUrlParam(next, "require_ssl", "true"); + if (mode === "required") { + next = setUrlParam(next, "verify_ca", "false"); + return setUrlParam(next, "verify_identity", "false"); + } + if (mode === "verify_ca") { + next = setUrlParam(next, "verify_ca", "true"); + return setUrlParam(next, "verify_identity", "false"); + } + next = setUrlParam(next, "verify_ca", "true"); + return setUrlParam(next, "verify_identity", "true"); +} + +function normalizePostgresSslMode(value: string): string { + switch (value.trim().toLowerCase()) { + case "disable": + case "prefer": + case "require": + case "verify-ca": + case "verify-full": + return value.trim().toLowerCase(); + case "verify_identity": + case "verify-identity": + return "verify-full"; + default: + return ""; + } +} + function normalizeRedisSentinelNodes(value: string): string { return value .split(/[\n,;]+/) @@ -985,6 +1129,55 @@ async function browseCaCertPath() { } } +async function browseMysqlTlsFile(target: "cert" | "key") { + if (isTauriRuntime()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: target === "cert" ? t("connection.mysqlClientCertBrowse") : t("connection.mysqlClientKeyBrowse"), + multiple: false, + filters: [ + { name: "PEM", extensions: ["pem", "crt", "cer", "key"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + if (selected && typeof selected === "string") { + if (target === "cert") { + mysqlClientCertPath.value = selected; + } else { + mysqlClientKeyPath.value = selected; + } + } + } +} + +async function browsePostgresTlsFile(target: "root" | "cert" | "key") { + if (isTauriRuntime()) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: + target === "root" + ? t("connection.postgresRootCertBrowse") + : target === "cert" + ? t("connection.postgresClientCertBrowse") + : t("connection.postgresClientKeyBrowse"), + multiple: false, + filters: [ + { name: "PEM", extensions: ["pem", "crt", "cer", "key"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + if (selected && typeof selected === "string") { + if (target === "root") { + postgresRootCertPath.value = selected; + } else if (target === "cert") { + postgresClientCertPath.value = selected; + } else { + postgresClientKeyPath.value = selected; + } + } + } +} + async function browseDbFilePath() { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -1708,7 +1901,10 @@ function openExternalUrl(url: string) {
-
+
+ + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a89ce5afd5..56b36216d4 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -122,6 +122,37 @@ export default { caCertPath: "CA Certificate", caCertPathPlaceholder: "Optional, e.g. ~/.yandex/RootCA.crt", caCertPathBrowse: "Browse certificate", + mysqlTlsMode: "TLS Mode", + mysqlTlsModePreferred: "Preferred", + mysqlTlsModeDisabled: "Disabled", + mysqlTlsModeRequired: "Required", + mysqlTlsModeVerifyCa: "Verify CA", + mysqlTlsModeVerifyIdentity: "Verify Identity", + mysqlCaCertHint: "Required for Verify CA and Verify Identity when the server certificate uses a private CA.", + mysqlClientCert: "Client Auth", + mysqlClientCertPlaceholder: "/path/to/client.crt", + mysqlClientKeyPlaceholder: "/path/to/client.key", + mysqlClientCertHint: "Client certificate and private key must be provided together when MySQL requires mTLS.", + mysqlClientCertBrowse: "Choose client certificate", + mysqlClientKeyBrowse: "Choose client private key", + postgresSslMode: "TLS Mode", + postgresSslModeDisable: "Disable", + postgresSslModePrefer: "Prefer", + postgresSslModeRequire: "Require", + postgresSslModeVerifyCa: "Verify CA", + postgresSslModeVerifyFull: "Verify Full", + postgresServerCert: "Server CA", + postgresRootCertPlaceholder: "/path/to/ca.crt", + postgresRootCertHint: + "Use this for verify-ca or verify-full when the server certificate is signed by a private CA.", + postgresRootCertBrowse: "Choose CA certificate", + postgresClientCert: "Client Auth", + postgresClientCertPlaceholder: "/path/to/client.crt", + postgresClientKeyPlaceholder: "/path/to/client.key", + postgresClientCertHint: + "Client certificate and private key must be provided together when PostgreSQL requires mTLS.", + postgresClientCertBrowse: "Choose client certificate", + postgresClientKeyBrowse: "Choose client private key", connectionUrlOptional: "URL (optional)", connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require", parseConnectionUrl: "Parse connection URL", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0be15cad3a..63b8b53e59 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -112,6 +112,39 @@ export default { driverName: "Nombre del driver", driverNamePlaceholder: "Nombre del proveedor o entorno", urlParams: "Parámetros de URL", + sslEnable: "Habilitar conexión cifrada", + caCertPath: "Certificado CA", + caCertPathPlaceholder: "Opcional, p. ej. ~/.yandex/RootCA.crt", + caCertPathBrowse: "Buscar certificado", + mysqlTlsMode: "Modo TLS", + mysqlTlsModePreferred: "Preferido", + mysqlTlsModeDisabled: "Deshabilitado", + mysqlTlsModeRequired: "Requerido", + mysqlTlsModeVerifyCa: "Verificar CA", + mysqlTlsModeVerifyIdentity: "Verificar identidad", + mysqlCaCertHint: "Requerido para Verificar CA e Identidad cuando el certificado usa una CA privada.", + mysqlClientCert: "Autenticación cliente", + mysqlClientCertPlaceholder: "/ruta/a/client.crt", + mysqlClientKeyPlaceholder: "/ruta/a/client.key", + mysqlClientCertHint: "El certificado y la clave privada deben indicarse juntos si MySQL requiere mTLS.", + mysqlClientCertBrowse: "Elegir certificado cliente", + mysqlClientKeyBrowse: "Elegir clave privada cliente", + postgresSslMode: "Modo TLS", + postgresSslModeDisable: "Deshabilitar", + postgresSslModePrefer: "Preferir", + postgresSslModeRequire: "Requerir", + postgresSslModeVerifyCa: "Verificar CA", + postgresSslModeVerifyFull: "Verificar completo", + postgresServerCert: "CA del servidor", + postgresRootCertPlaceholder: "/ruta/a/ca.crt", + postgresRootCertHint: "Úsalo con verify-ca o verify-full cuando el certificado del servidor use una CA privada.", + postgresRootCertBrowse: "Elegir certificado CA", + postgresClientCert: "Autenticación cliente", + postgresClientCertPlaceholder: "/ruta/a/client.crt", + postgresClientKeyPlaceholder: "/ruta/a/client.key", + postgresClientCertHint: "El certificado y la clave privada deben indicarse juntos si PostgreSQL requiere mTLS.", + postgresClientCertBrowse: "Elegir certificado cliente", + postgresClientKeyBrowse: "Elegir clave privada cliente", connectionUrlOptional: "URL (opcional)", connectionUrlPlaceholder: "postgresql://usuario:contraseña{'@'}host:5432/db?sslmode=require", parseConnectionUrl: "Analizar URL de conexión", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1ddd36d519..e4481fc888 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -121,6 +121,35 @@ export default { caCertPath: "CA 证书", caCertPathPlaceholder: "可选,例如 ~/.yandex/RootCA.crt", caCertPathBrowse: "选择证书", + mysqlTlsMode: "TLS 模式", + mysqlTlsModePreferred: "优先使用", + mysqlTlsModeDisabled: "禁用", + mysqlTlsModeRequired: "必须使用", + mysqlTlsModeVerifyCa: "验证 CA", + mysqlTlsModeVerifyIdentity: "验证身份", + mysqlCaCertHint: "服务端证书由私有 CA 签发时,Verify CA / Verify Identity 模式需要填写。", + mysqlClientCert: "客户端认证", + mysqlClientCertPlaceholder: "/path/to/client.crt", + mysqlClientKeyPlaceholder: "/path/to/client.key", + mysqlClientCertHint: "MySQL 要求 mTLS 时,客户端证书和私钥必须一起填写。", + mysqlClientCertBrowse: "选择客户端证书", + mysqlClientKeyBrowse: "选择客户端私钥", + postgresSslMode: "TLS 模式", + postgresSslModeDisable: "禁用", + postgresSslModePrefer: "优先使用", + postgresSslModeRequire: "必须使用", + postgresSslModeVerifyCa: "验证 CA", + postgresSslModeVerifyFull: "完整验证", + postgresServerCert: "服务端 CA", + postgresRootCertPlaceholder: "/path/to/ca.crt", + postgresRootCertHint: "服务端证书由私有 CA 签发时,在 verify-ca 或 verify-full 模式下填写。", + postgresRootCertBrowse: "选择 CA 证书", + postgresClientCert: "客户端认证", + postgresClientCertPlaceholder: "/path/to/client.crt", + postgresClientKeyPlaceholder: "/path/to/client.key", + postgresClientCertHint: "PostgreSQL 要求 mTLS 时,客户端证书和私钥必须一起填写。", + postgresClientCertBrowse: "选择客户端证书", + postgresClientKeyBrowse: "选择客户端私钥", connectionUrlOptional: "URL(可选)", connectionUrlPlaceholder: "postgresql://user:pass{'@'}host:5432/db?sslmode=require", parseConnectionUrl: "解析连接 URL", diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 9efbf7172d..4563bf3fdf 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -29,6 +29,7 @@ mysql_async = { version = "0.36", default-features = false, features = ["default sqlparser = "0.62.0" redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "sentinel"] } rustls = { version = "0.23", features = ["aws-lc-rs"] } +rustls-pemfile = "2.2" duckdb = "1.3.2" tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] } diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index c12722f5c9..149e2ac2c7 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -2,6 +2,7 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use futures::StreamExt; use mysql_async::consts::ColumnType; use mysql_async::prelude::*; +use percent_encoding::percent_decode_str; use rust_decimal::Decimal; use std::borrow::Cow; use std::collections::HashSet; @@ -14,6 +15,8 @@ use crate::types::{ ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectInfo, QueryResult, TableInfo, TriggerInfo, }; +use super::file_validator::validate_file_path; + pub type MySqlPool = mysql_async::Pool; fn quote_value(s: &str) -> String { @@ -280,8 +283,17 @@ pub async fn connect_with_ca_cert(url: &str, ca_cert_path: Option<&str>) -> Resu result.map(|_| pool) } +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct MySqlTlsFiles { + sslcert: Option, + sslkey: Option, +} + fn create_pool(url: &str, ca_cert_path: Option<&str>) -> Result { - let opts = mysql_async::Opts::from_url(&mysql_async_url(url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?; + let tls_url = mysql_tls_url(url)?; + let opts = + mysql_async::Opts::from_url(&mysql_async_url(&tls_url.url)).map_err(|e| format!("Invalid MySQL URL: {e}"))?; + let base_ssl_opts = opts.ssl_opts().cloned(); let pool_opts = mysql_async::PoolOpts::new() .with_constraints(mysql_async::PoolConstraints::new(1, 1).unwrap()) .with_inactive_connection_ttl(Duration::from_secs(300)); @@ -290,17 +302,109 @@ fn create_pool(url: &str, ca_cert_path: Option<&str>) -> Result Result { + let Some(query_start) = url.find('?') else { + return Ok(MySqlTlsUrl { url: url.to_string(), files: MySqlTlsFiles::default() }); + }; + + let prefix = &url[..query_start]; + let suffix = &url[query_start + 1..]; + let (query_string, fragment) = suffix.split_once('#').map_or((suffix, ""), |(query, fragment)| (query, fragment)); + let mut files = MySqlTlsFiles::default(); + let mut kept_params = Vec::new(); + + for param in query_string.split('&') { + if param.is_empty() { + continue; + } + + let Some((key, value)) = param.split_once('=') else { + kept_params.push(param.to_string()); + continue; + }; + + if mysql_tls_file_param_is(key, "cert") || mysql_tls_file_param_is(key, "key") { + let decoded = percent_decode_str(value) + .decode_utf8() + .map_err(|_| format!("Invalid URL encoding in {key}"))? + .into_owned(); + validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?; + + if mysql_tls_file_param_is(key, "cert") { + files.sslcert = Some(decoded); + } else { + files.sslkey = Some(decoded); + } + } else { + kept_params.push(param.to_string()); + } + } + + let mut sanitized_url = prefix.to_string(); + if !kept_params.is_empty() { + sanitized_url.push('?'); + sanitized_url.push_str(&kept_params.join("&")); + } + if !fragment.is_empty() { + sanitized_url.push('#'); + sanitized_url.push_str(fragment); + } + + Ok(MySqlTlsUrl { url: sanitized_url, files }) +} + +fn mysql_tls_file_param_is(key: &str, target: &str) -> bool { + let normalized = key.to_ascii_lowercase().replace(['-', '_'], ""); + normalized == format!("ssl{target}") +} + +fn mysql_ssl_opts( + base_ssl_opts: Option, + url: &str, + ca_cert_path: Option<&str>, + files: &MySqlTlsFiles, +) -> Result, String> { + let ca_cert_path = ca_cert_path.map(str::trim).filter(|path| !path.is_empty()); + let has_client_identity = files.sslcert.as_deref().is_some() || files.sslkey.as_deref().is_some(); + if !mysql_url_requires_ssl(url) && !has_client_identity { + return Ok(None); + } + + let mut ssl_opts = base_ssl_opts.unwrap_or_default(); + if let Some(ca_cert_path) = ca_cert_path.filter(|_| mysql_url_requires_ssl(url) || has_client_identity) { + ssl_opts = ssl_opts.with_root_certs(vec![PathBuf::from(ca_cert_path).into()]); + if !mysql_url_verifies_identity(url) { + ssl_opts = ssl_opts.with_danger_skip_domain_validation(true); + } + } + + match (files.sslcert.as_deref(), files.sslkey.as_deref()) { + (Some(cert_path), Some(key_path)) => { + ssl_opts = ssl_opts.with_client_identity(Some(mysql_async::ClientIdentity::new( + PathBuf::from(cert_path).into(), + PathBuf::from(key_path).into(), + ))); + } + (Some(_), None) => return Err("MySQL ssl-cert requires ssl-key".to_string()), + (None, Some(_)) => return Err("MySQL ssl-key requires ssl-cert".to_string()), + (None, None) => {} + } + + Ok(Some(ssl_opts)) +} + fn mysql_setup_queries(url: &str) -> Vec { let charset = mysql_connection_charset(url).unwrap_or("utf8mb4"); vec![format!("SET NAMES {charset}")] @@ -424,6 +528,8 @@ fn mysql_url_requires_ssl(url: &str) -> bool { let key = key.trim(); let value = value.trim(); (key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true")) + || mysql_tls_file_param_is(key, "cert") + || mysql_tls_file_param_is(key, "key") || ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) && matches!( value.to_ascii_lowercase().replace('-', "_").as_str(), @@ -432,6 +538,22 @@ fn mysql_url_requires_ssl(url: &str) -> bool { }) } +fn mysql_url_verifies_identity(url: &str) -> bool { + let Some((_, query)) = url.split_once('?') else { + return false; + }; + query.split('&').any(|segment| { + let Some((key, value)) = segment.split_once('=') else { + return false; + }; + let key = key.trim(); + let value = value.trim(); + (key.eq_ignore_ascii_case("verify_identity") && value.eq_ignore_ascii_case("true")) + || ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode")) + && matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "verify_identity")) + }) +} + fn mysql_async_url(url: &str) -> Cow<'_, str> { let Some((base, query)) = url.split_once('?') else { return Cow::Borrowed(url); @@ -1082,6 +1204,43 @@ mod tests { assert!(mysql_error_should_retry_without_ssl(error)); } + #[test] + fn mysql_tls_url_strips_client_identity_params_before_driver_parse() { + let dir = std::env::temp_dir(); + let cert = dir.join(format!("dbx-mysql-client-cert-{}.pem", std::process::id())); + let key = dir.join(format!("dbx-mysql-client-key-{}.pem", std::process::id())); + std::fs::write(&cert, "not a real cert").unwrap(); + std::fs::write(&key, "not a real key").unwrap(); + + let url = format!( + "mysql://root:secret@localhost/test?require_ssl=true&ssl-cert={}&ssl-key={}&charset=utf8mb4", + cert.display(), + key.display() + ); + let parsed = mysql_tls_url(&url).unwrap(); + + assert_eq!(parsed.url, "mysql://root:secret@localhost/test?require_ssl=true&charset=utf8mb4"); + assert_eq!(parsed.files.sslcert.as_deref(), Some(cert.to_str().unwrap())); + assert_eq!(parsed.files.sslkey.as_deref(), Some(key.to_str().unwrap())); + mysql_async::Opts::from_url(&mysql_async_url(&parsed.url)).unwrap(); + + let _ = std::fs::remove_file(cert); + let _ = std::fs::remove_file(key); + } + + #[test] + fn mysql_tls_rejects_unpaired_client_cert_and_key() { + let files = MySqlTlsFiles { sslcert: Some("/tmp/client.crt".to_string()), sslkey: None }; + + let error = mysql_ssl_opts(None, "mysql://root@localhost/db?require_ssl=true", None, &files).unwrap_err(); + assert!(error.contains("ssl-key")); + } + + #[test] + fn mysql_tls_client_identity_requires_ssl() { + assert!(mysql_url_requires_ssl("mysql://root@localhost/db?ssl-cert=/tmp/client.crt&ssl-key=/tmp/client.key")); + } + #[test] fn mysql_unknown_error_can_retry_with_text_protocol() { let error = "error returned from database: 1105 (HY000): Unknown error"; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 1023ab016c..9358214ad0 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -4,8 +4,12 @@ use futures::{SinkExt, StreamExt}; use percent_encoding::percent_decode_str; use rust_decimal::Decimal; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::client::verify_server_cert_signed_by_trust_anchor; use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider}; -use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::server::ParsedCertificate; +use std::fs::File; +use std::io::BufReader; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; @@ -319,17 +323,22 @@ async fn execute_select_query( } pub async fn connect(url: &str) -> Result { - validate_postgres_ssl_paths(url)?; + let postgres_url = postgres_connection_url(url)?; let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let tz = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string()); super::with_connection_timeout("PostgreSQL", super::connection_timeout(), async { - let pg_config = - tokio_postgres::Config::from_str(url).map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?; + let pg_config = tokio_postgres::Config::from_str(&postgres_url.url) + .map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?; let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast }; - let tls_config = postgres_tls_config(&pg_config); + let tls_config = postgres_tls_config( + &pg_config, + &postgres_url.ssl_files, + postgres_url.accepts_invalid_certs, + postgres_url.verifies_hostname, + )?; let mgr = deadpool_postgres::Manager::from_config( pg_config.clone(), tokio_postgres_rustls::MakeRustlsConnect::new(tls_config), @@ -357,18 +366,182 @@ pub async fn connect(url: &str) -> Result { .await } -fn postgres_tls_config(pg_config: &tokio_postgres::Config) -> rustls::ClientConfig { - if postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()) { - let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); - return rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoPostgresCertVerification { provider })) - .with_no_client_auth(); +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct PostgresSslFiles { + sslcert: Option, + sslkey: Option, + sslrootcert: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PostgresConnectionUrl { + url: String, + ssl_files: PostgresSslFiles, + accepts_invalid_certs: bool, + verifies_hostname: bool, +} + +fn postgres_connection_url(url: &str) -> Result { + let Some(query_start) = url.find('?') else { + let pg_config = + tokio_postgres::Config::from_str(url).map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?; + return Ok(PostgresConnectionUrl { + url: url.to_string(), + ssl_files: PostgresSslFiles::default(), + accepts_invalid_certs: postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()), + verifies_hostname: false, + }); + }; + + let prefix = &url[..query_start]; + let suffix = &url[query_start + 1..]; + let (query_string, fragment) = suffix.split_once('#').map_or((suffix, ""), |(query, fragment)| (query, fragment)); + let mut ssl_files = PostgresSslFiles::default(); + let mut kept_params = Vec::new(); + let mut accepts_invalid_certs = true; + let mut verifies_hostname = false; + + for param in query_string.split('&') { + if param.is_empty() { + continue; + } + + let Some((key, value)) = param.split_once('=') else { + kept_params.push(param.to_string()); + continue; + }; + + if key.eq_ignore_ascii_case("sslcert") + || key.eq_ignore_ascii_case("sslkey") + || key.eq_ignore_ascii_case("sslrootcert") + { + let decoded = percent_decode_str(value) + .decode_utf8() + .map_err(|_| format!("Invalid URL encoding in {key}"))? + .into_owned(); + validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?; + + if key.eq_ignore_ascii_case("sslcert") { + ssl_files.sslcert = Some(decoded); + } else if key.eq_ignore_ascii_case("sslkey") { + ssl_files.sslkey = Some(decoded); + } else { + ssl_files.sslrootcert = Some(decoded); + } + } else if key.eq_ignore_ascii_case("sslmode") { + match value.to_ascii_lowercase().as_str() { + "verify-ca" => { + accepts_invalid_certs = false; + kept_params.push("sslmode=require".to_string()); + } + "verify-full" | "verify_identity" | "verify-identity" => { + accepts_invalid_certs = false; + verifies_hostname = true; + kept_params.push("sslmode=require".to_string()); + } + "disable" => { + accepts_invalid_certs = false; + kept_params.push(param.to_string()); + } + "prefer" | "require" => { + accepts_invalid_certs = true; + kept_params.push(param.to_string()); + } + _ => kept_params.push(param.to_string()), + } + } else { + kept_params.push(param.to_string()); + } } + let mut sanitized_url = prefix.to_string(); + if !kept_params.is_empty() { + sanitized_url.push('?'); + sanitized_url.push_str(&kept_params.join("&")); + } + if !fragment.is_empty() { + sanitized_url.push('#'); + sanitized_url.push_str(fragment); + } + + Ok(PostgresConnectionUrl { url: sanitized_url, ssl_files, accepts_invalid_certs, verifies_hostname }) +} + +fn postgres_tls_config( + pg_config: &tokio_postgres::Config, + ssl_files: &PostgresSslFiles, + accepts_invalid_certs: bool, + verifies_hostname: bool, +) -> Result { + if pg_config.get_ssl_mode() != SslMode::Disable && accepts_invalid_certs { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let builder = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoPostgresCertVerification { provider })); + return postgres_tls_client_auth(builder, ssl_files); + } + + let root_store = postgres_root_cert_store(ssl_files)?; + let builder = if verifies_hostname { + rustls::ClientConfig::builder().with_root_certificates(root_store) + } else { + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + rustls::ClientConfig::builder().dangerous().with_custom_certificate_verifier(Arc::new( + PostgresCaOnlyCertVerification { provider, roots: Arc::new(root_store) }, + )) + }; + postgres_tls_client_auth(builder, ssl_files) +} + +fn postgres_root_cert_store(ssl_files: &PostgresSslFiles) -> Result { let mut root_store = rustls::RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - rustls::ClientConfig::builder().with_root_certificates(root_store).with_no_client_auth() + if let Some(path) = ssl_files.sslrootcert.as_deref() { + let certs = read_postgres_pem_certs("sslrootcert", path)?; + let (valid_count, _) = root_store.add_parsable_certificates(certs); + if valid_count == 0 { + return Err(format!("sslrootcert: no valid CA certificates found in {path}")); + } + } else { + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + Ok(root_store) +} + +fn postgres_tls_client_auth( + builder: rustls::ConfigBuilder, + ssl_files: &PostgresSslFiles, +) -> Result { + match (ssl_files.sslcert.as_deref(), ssl_files.sslkey.as_deref()) { + (Some(cert_path), Some(key_path)) => { + let certs = read_postgres_pem_certs("sslcert", cert_path)?; + if certs.is_empty() { + return Err(format!("sslcert: no certificates found in {cert_path}")); + } + let private_key = read_postgres_private_key(key_path)?; + builder + .with_client_auth_cert(certs, private_key) + .map_err(|e| format!("PostgreSQL client certificate/key mismatch or invalid key: {e}")) + } + (Some(_), None) => Err("PostgreSQL sslcert requires sslkey".to_string()), + (None, Some(_)) => Err("PostgreSQL sslkey requires sslcert".to_string()), + (None, None) => Ok(builder.with_no_client_auth()), + } +} + +fn read_postgres_pem_certs(label: &str, path: &str) -> Result>, String> { + let file = File::open(path).map_err(|e| format!("{label}: failed to open {path}: {e}"))?; + let mut reader = BufReader::new(file); + rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| format!("{label}: failed to read PEM certificates from {path}: {e}")) +} + +fn read_postgres_private_key(path: &str) -> Result, String> { + let file = File::open(path).map_err(|e| format!("sslkey: failed to open {path}: {e}"))?; + let mut reader = BufReader::new(file); + rustls_pemfile::private_key(&mut reader) + .map_err(|e| format!("sslkey: failed to read PEM private key from {path}: {e}"))? + .ok_or_else(|| format!("sslkey: no private key found in {path}")) } fn postgres_sslmode_accepts_invalid_certs(ssl_mode: SslMode) -> bool { @@ -415,6 +588,55 @@ impl ServerCertVerifier for NoPostgresCertVerification { } } +#[derive(Debug)] +struct PostgresCaOnlyCertVerification { + provider: Arc, + roots: Arc, +} + +impl ServerCertVerifier for PostgresCaOnlyCertVerification { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + let cert = ParsedCertificate::try_from(end_entity)?; + verify_server_cert_signed_by_trust_anchor( + &cert, + &self.roots, + intermediates, + now, + self.provider.signature_verification_algorithms.all, + )?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider.signature_verification_algorithms.supported_schemes() + } +} + /// Check whether the user's connection URL already specifies a timezone via /// the `options` parameter so we don't overwrite it with the local timezone. fn pg_url_has_timezone_setting(url: &str) -> bool { @@ -431,27 +653,9 @@ fn pg_url_has_timezone_setting(url: &str) -> bool { false } +#[cfg(test)] fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> { - if let Some(query_start) = url.find('?') { - let query_string = &url[query_start + 1..]; - - for param in query_string.split('&') { - if let Some((key, value)) = param.split_once('=') { - match key { - "sslcert" | "sslkey" | "sslrootcert" => { - let decoded = percent_decode_str(value) - .decode_utf8() - .map_err(|_| format!("Invalid URL encoding in {key}"))?; - - validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?; - } - _ => {} - } - } - } - } - - Ok(()) + postgres_connection_url(url).map(|_| ()) } pub async fn list_databases(pool: &Pool) -> Result, String> { @@ -1023,6 +1227,60 @@ mod tests { assert!(result.is_err()); } + #[test] + fn postgres_connection_url_strips_ssl_file_params_before_driver_parse() { + let dir = std::env::temp_dir(); + let cert = dir.join(format!("dbx-postgres-cert-{}.pem", std::process::id())); + let key = dir.join(format!("dbx-postgres-key-{}.pem", std::process::id())); + let root = dir.join(format!("dbx-postgres-root-{}.pem", std::process::id())); + std::fs::write(&cert, "not a real cert").unwrap(); + std::fs::write(&key, "not a real key").unwrap(); + std::fs::write(&root, "not a real root").unwrap(); + + let url = format!( + "postgres://localhost/db?sslmode=verify-full&sslcert={}&sslkey={}&sslrootcert={}&application_name=dbx", + cert.display(), + key.display(), + root.display() + ); + let parsed = postgres_connection_url(&url).unwrap(); + + assert_eq!(parsed.url, "postgres://localhost/db?sslmode=require&application_name=dbx"); + assert_eq!(parsed.ssl_files.sslcert.as_deref(), Some(cert.to_str().unwrap())); + assert_eq!(parsed.ssl_files.sslkey.as_deref(), Some(key.to_str().unwrap())); + assert_eq!(parsed.ssl_files.sslrootcert.as_deref(), Some(root.to_str().unwrap())); + assert!(!parsed.accepts_invalid_certs); + assert!(parsed.verifies_hostname); + tokio_postgres::Config::from_str(&parsed.url).unwrap(); + + let _ = std::fs::remove_file(cert); + let _ = std::fs::remove_file(key); + let _ = std::fs::remove_file(root); + } + + #[test] + fn postgres_connection_url_keeps_verify_ca_ca_only_semantics() { + let parsed = postgres_connection_url("postgres://localhost/db?sslmode=verify-ca").unwrap(); + + assert_eq!(parsed.url, "postgres://localhost/db?sslmode=require"); + assert!(!parsed.accepts_invalid_certs); + assert!(!parsed.verifies_hostname); + } + + #[test] + fn postgres_tls_rejects_unpaired_client_cert_and_key() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=require").unwrap(); + let ssl_files = + PostgresSslFiles { sslcert: Some("/tmp/client.crt".to_string()), sslkey: None, sslrootcert: None }; + + let error = match postgres_tls_config(&pg_config, &ssl_files, true, false) { + Ok(_) => panic!("expected missing sslkey to fail"), + Err(error) => error, + }; + assert!(error.contains("sslkey")); + } + #[test] fn postgres_tls_accepts_invalid_certs_for_require_sslmode() { let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=require").unwrap(); diff --git a/crates/dbx-core/tests/live_mtls.rs b/crates/dbx-core/tests/live_mtls.rs new file mode 100644 index 0000000000..13d6cbc578 --- /dev/null +++ b/crates/dbx-core/tests/live_mtls.rs @@ -0,0 +1,24 @@ +#[tokio::test] +#[ignore = "requires the remote DBX mTLS smoke-test containers and local client certificates"] +async fn live_mysql_mtls_connection_succeeds() { + let url = std::env::var("DBX_LIVE_MYSQL_MTLS_URL").expect("DBX_LIVE_MYSQL_MTLS_URL"); + let ca = std::env::var("DBX_LIVE_MYSQL_MTLS_CA").ok(); + + let pool = dbx_core::db::mysql::connect_with_ca_cert(&url, ca.as_deref()).await.unwrap(); + let result = + dbx_core::db::mysql::execute_query(&pool, "SELECT label FROM mtls_smoke WHERE id = 1", false).await.unwrap(); + + assert_eq!(result.rows[0][0], serde_json::json!("mysql mtls ok")); +} + +#[tokio::test] +#[ignore = "requires the remote DBX mTLS smoke-test containers and local client certificates"] +async fn live_postgres_mtls_connection_succeeds() { + let url = std::env::var("DBX_LIVE_POSTGRES_MTLS_URL").expect("DBX_LIVE_POSTGRES_MTLS_URL"); + + let pool = dbx_core::db::postgres::connect(&url).await.unwrap(); + let result = + dbx_core::db::postgres::execute_query(&pool, "SELECT label FROM mtls_smoke WHERE id = 1").await.unwrap(); + + assert_eq!(result.rows[0][0], serde_json::json!("postgres mtls ok")); +} diff --git a/packages/app-tests/connectionDialogTls.test.ts b/packages/app-tests/connectionDialogTls.test.ts index cb9dc7e9d2..0d09eb8dd1 100644 --- a/packages/app-tests/connectionDialogTls.test.ts +++ b/packages/app-tests/connectionDialogTls.test.ts @@ -17,8 +17,37 @@ test("connection dialog exposes generic TLS controls for supported database type }); test("connection dialog exposes CA certificate path for native MySQL TLS", () => { - assert.match(source, /const supportsCaCertificatePath = computed/); + assert.match(source, /const supportsMysqlTlsOptions = computed/); assert.match(source, /form\.value\.db_type === "mysql"/); - assert.match(source, /v-if="supportsCaCertificatePath"/); + assert.match(source, /const mysqlTlsMode = computed/); + assert.match(source, /const mysqlClientCertPath = computed/); + assert.match(source, /const mysqlClientKeyPath = computed/); + assert.match(source, /v-model="mysqlTlsMode"/); assert.match(source, /v-model="form\.ca_cert_path"/); + assert.match(source, /v-model="mysqlClientCertPath"/); + assert.match(source, /v-model="mysqlClientKeyPath"/); + assert.match(source, /setUrlParam\(next, "ssl-cert"/); + assert.match(source, /setUrlParam\(next, "ssl-key"/); + assert.match(source, /const bareMysqlProfiles = new Set/); + assert.match(source, /"oceanbase"/); + assert.match(source, /"doris"/); + assert.match(source, /"starrocks"/); + assert.match(source, /"selectdb"/); +}); + +test("connection dialog exposes PostgreSQL TLS certificate controls", () => { + assert.match(source, /const nativePostgresTlsDatabaseTypes = new Set/); + assert.match(source, /const supportsPostgresTlsOptions = computed/); + assert.match(source, /"postgres"/); + assert.match(source, /"redshift"/); + assert.match(source, /"gaussdb"/); + assert.match(source, /"opengauss"/); + assert.match(source, /const postgresTlsMode = computed/); + assert.match(source, /v-model="postgresTlsMode"/); + assert.match(source, /v-model="postgresRootCertPath"/); + assert.match(source, /v-model="postgresClientCertPath"/); + assert.match(source, /v-model="postgresClientKeyPath"/); + assert.match(source, /setUrlParam\(form\.value\.url_params, "sslrootcert"/); + assert.match(source, /setUrlParam\(form\.value\.url_params, "sslcert"/); + assert.match(source, /setUrlParam\(form\.value\.url_params, "sslkey"/); });