diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index fd13b0ac4..88e3af1e9 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1146,6 +1146,7 @@ impl AppState { let configs = self.configs.read().await; configs.get(connection_id).ok_or("Connection config not found")?.clone() }; + validate_connection_url_params(&config)?; let db_type = Some(config.db_type); let validate_existing_pool = should_validate_existing_pool_before_reuse(config.db_type); @@ -3728,6 +3729,11 @@ fn native_postgres_url_config(config: &ConnectionConfig) -> Option Result<(), String> { + let normalized = native_postgres_url_config(config); + normalized.as_ref().unwrap_or(config).validate_native_url_params() +} + pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, port: u16) -> Result<(), String> { if !uses_tcp_probe(config, host, port) { return Ok(()); @@ -3825,7 +3831,8 @@ mod tests { oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, task_client_session_id, uses_bare_mysql_pool, - uses_tcp_probe, validate_h2_database_path, AppState, MysqlMode, PoolKind, PRESTOSQL_JDBC_DRIVER_CLASS, + uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, + PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, @@ -4761,6 +4768,18 @@ mod tests { ); } + #[test] + fn postgres_url_validation_rejects_invalid_stringtype_before_connect() { + let mut config = mysql_config(Some("postgres")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some("currentSchema=public&stringtype=text".to_string()); + + assert_eq!( + validate_connection_url_params(&config).unwrap_err(), + "Unsupported value for PostgreSQL stringtype parameter: text. Expected 'unspecified' or 'varchar'." + ); + } + #[test] fn mongodb_database_connection_keeps_saved_database_for_auth() { let mut config = mysql_config(Some("admin")); diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index fd74d2f85..0ee31af54 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -942,7 +942,7 @@ impl ConnectionConfig { let raw_host = host; let host = bracket_ipv6(host); let db_part = self.effective_database().map(|d| format!("/{}", encode_url_part(d))).unwrap_or_default(); - let params = self.normalized_url_params(); + let params = self.redacted_url_params(); match self.db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => { @@ -1359,6 +1359,23 @@ impl ConnectionConfig { } } + fn redacted_url_params(&self) -> String { + let params = self.normalized_url_params(); + if matches!(self.db_type, DatabaseType::Postgres | DatabaseType::Redshift) { + redact_postgres_url_params(¶ms) + } else { + params + } + } + + pub(crate) fn validate_native_url_params(&self) -> Result<(), String> { + if matches!(self.db_type, DatabaseType::Postgres | DatabaseType::Redshift) { + validate_postgres_url_params(self.url_params.as_deref().unwrap_or("")) + } else { + Ok(()) + } + } + pub fn clickhouse_uses_tls(&self) -> bool { self.ssl || url_params_contains_flag(self.url_params.as_deref(), "secure", "true") } @@ -1760,12 +1777,17 @@ fn normalize_postgres_url_params(value: &str, force_tls: bool) -> String { "verify-full" | "verify-identity" => parts.push("sslmode=verify-full".to_string()), _ => {} } + } else if key.eq_ignore_ascii_case("host") + || key.eq_ignore_ascii_case("hostaddr") + || key.eq_ignore_ascii_case("port") + || key.eq_ignore_ascii_case("stringtype") + { } else if key.eq_ignore_ascii_case("charset") || key.eq_ignore_ascii_case("require_ssl") || key.eq_ignore_ascii_case("verify_ca") || key.eq_ignore_ascii_case("verify_identity") { - // These MySQL-style parameters may be present in older/imported + // Driver-specific parameters may be present in older/imported // saved connections. tokio-postgres rejects unknown URL keys. } else { parts.push(part.to_string()); @@ -1818,6 +1840,35 @@ fn normalize_postgres_url_params(value: &str, force_tls: bool) -> String { parts.join("&") } +fn redact_postgres_url_params(value: &str) -> String { + value + .split('&') + .filter(|part| !part.is_empty() && !url_param_key_is(part, "password")) + .collect::>() + .join("&") +} + +fn validate_postgres_url_params(value: &str) -> Result<(), String> { + for part in value.trim().trim_start_matches('?').split('&').filter(|part| !part.is_empty()) { + let (raw_key, raw_value) = part.split_once('=').unwrap_or((part, "")); + if !percent_decode_str(raw_key).decode_utf8_lossy().eq_ignore_ascii_case("stringtype") { + continue; + } + + let string_type = percent_decode_str(raw_value).decode_utf8_lossy(); + if string_type.eq_ignore_ascii_case("unspecified") || string_type.eq_ignore_ascii_case("varchar") { + continue; + } + + let value = if string_type.is_empty() { "" } else { string_type.as_ref() }; + return Err(format!( + "Unsupported value for PostgreSQL stringtype parameter: {value}. Expected 'unspecified' or 'varchar'." + )); + } + + Ok(()) +} + fn url_param_key_is(part: &str, expected: &str) -> bool { let key = part.split_once('=').map(|(key, _)| key).unwrap_or(part); percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case(expected) @@ -2875,6 +2926,104 @@ mod tests { assert_eq!(pg_config.get_options(), Some("-c search_path=app")); } + #[test] + fn postgres_url_ignores_jdbc_stringtype_param() { + let mut config = mysql_config("postgres", "secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some("currentSchema=public&stringtype=unspecified".to_string()); + + assert_eq!(config.validate_native_url_params(), Ok(())); + assert_eq!( + config.connection_url(), + "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=prefer&options=%2Dc%20search%5Fpath%3Dpublic" + ); + let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); + assert_eq!(pg_config.get_options(), Some("-c search_path=public")); + } + + #[test] + fn postgres_url_accepts_encoded_jdbc_varchar_stringtype_param() { + let mut config = mysql_config("postgres", "secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some("currentSchema=app&%73tringtype=%76aRcHaR".to_string()); + + assert_eq!(config.validate_native_url_params(), Ok(())); + assert_eq!( + config.connection_url(), + "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=prefer&options=%2Dc%20search%5Fpath%3Dapp" + ); + let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); + assert_eq!(pg_config.get_options(), Some("-c search_path=app")); + } + + #[test] + fn postgres_url_rejects_invalid_or_empty_jdbc_stringtype_param() { + let mut config = mysql_config("postgres", "secret", Some("test")); + config.db_type = DatabaseType::Postgres; + + config.url_params = Some("stringtype=invalid".to_string()); + assert_eq!( + config.validate_native_url_params().unwrap_err(), + "Unsupported value for PostgreSQL stringtype parameter: invalid. Expected 'unspecified' or 'varchar'." + ); + + config.url_params = Some(" ?%73tringtype= ".to_string()); + assert_eq!( + config.validate_native_url_params().unwrap_err(), + "Unsupported value for PostgreSQL stringtype parameter: . Expected 'unspecified' or 'varchar'." + ); + } + + #[test] + fn postgres_url_uses_only_the_structured_endpoint() { + let mut config = mysql_config("postgres", "secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some( + "HOST=origin.example.com&%68ostaddr=203.0.113.10&%70ort=6432¤tSchema=app&application_name=dbx" + .to_string(), + ); + + let url = config.connection_url_with_host("127.0.0.1", 6543); + + assert_eq!( + url, + "postgres://postgres:secret@127.0.0.1:6543/test?sslmode=prefer&application_name=dbx&options=%2Dc%20search%5Fpath%3Dapp" + ); + let pg_config = tokio_postgres::Config::from_str(&url).unwrap(); + assert_eq!(pg_config.get_hosts().len(), 1); + assert_eq!(pg_config.get_ports(), &[6543]); + assert!(pg_config.get_hostaddrs().is_empty()); + assert_eq!(pg_config.get_options(), Some("-c search_path=app")); + } + + #[test] + fn postgres_url_query_password_keeps_priority_but_is_redacted() { + let mut config = mysql_config("postgres", "field-secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some("%70assword=query-secret&application_name=dbx".to_string()); + + let url = config.connection_url(); + let pg_config = tokio_postgres::Config::from_str(&url).unwrap(); + assert_eq!(pg_config.get_password(), Some(b"query-secret".as_slice())); + assert_eq!( + config.redacted_connection_url(), + "postgres://10.1.2.3:2883/test?sslmode=prefer&application_name=dbx" + ); + } + + #[test] + fn postgres_url_redacts_case_insensitive_and_encoded_password_keys() { + let mut config = mysql_config("postgres", "field-secret", Some("test")); + config.db_type = DatabaseType::Postgres; + config.url_params = Some("PASSWORD=upper-secret&%70assword=encoded-secret&application_name=dbx".to_string()); + + let url = config.redacted_connection_url(); + + assert_eq!(url, "postgres://10.1.2.3:2883/test?sslmode=prefer&application_name=dbx"); + assert!(!url.contains("upper-secret")); + assert!(!url.contains("encoded-secret")); + } + #[test] fn postgres_url_ignores_mysql_only_params_from_saved_connections() { let mut config = mysql_config("postgres", "secret", Some("test")); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 0c78cb953..133b7ea16 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -4812,6 +4812,7 @@ async fn native_postgres_metadata_pool( let mut postgres_config = database_connection_config(config, Some(database)); postgres_config.db_type = DatabaseType::Postgres; + postgres_config.validate_native_url_params()?; let (host, port) = state.connection_host_port(connection_id, &postgres_config).await?; let url = connection_url_for_endpoint(&postgres_config, &host, port); let connect_timeout = Duration::from_secs(postgres_config.effective_connect_timeout_secs());