fix(postgres): sanitize JDBC URL compatibility parameters

This commit is contained in:
zipg 2026-07-27 15:03:32 +08:00 committed by GitHub
parent 0b4b1f956f
commit fddc0fdf50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 172 additions and 3 deletions

View File

@ -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"));

View File

@ -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(&params)
} 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&currentSchema=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"));

View File

@ -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());