fix(postgres): sanitize JDBC URL compatibility parameters
This commit is contained in:
parent
0b4b1f956f
commit
fddc0fdf50
|
|
@ -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<ConnectionCon
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_connection_url_params(config: &ConnectionConfig) -> 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"));
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>()
|
||||
.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() { "<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: <empty>. 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"));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue