fix(plugin): persist plugin connection secrets safely

This commit is contained in:
t8y2
2026-09-13 08:36:00 +08:00
parent 6b466b5c25
commit e73d241596
9 changed files with 182 additions and 7 deletions
+47 -2
View File
@@ -15,7 +15,7 @@ use crate::ai::AiConfigItem;
use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TRUSTSTORE_PASSWORD_KEY, MQ_AUTH_API_KEY_VALUE_KEY,
MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
use crate::saved_sql::SavedSqlLibrary;
@@ -973,6 +973,7 @@ fn scrub_connection_secrets(config: &mut ConnectionConfig) {
scrub_mq_external_config_secrets(config);
scrub_nacos_auth_secrets(config);
scrub_cassandra_tls_secrets(config);
config.connection_secrets.clear();
}
fn scrub_mqtt_auth_secrets(config: &mut ConnectionConfig) {
@@ -1008,6 +1009,14 @@ async fn build_sensitive_payload(
if config.save_password {
push_secret(&mut connection_secrets, &config.id, "password", &config.password);
}
for (key, secret) in &config.connection_secrets {
push_secret(
&mut connection_secrets,
&config.id,
&format!("{PLUGIN_CONNECTION_SECRET_PREFIX}{key}"),
secret,
);
}
push_secret(&mut connection_secrets, &config.id, "init_script", config.init_script.as_deref().unwrap_or(""));
for (index, layer) in config.transport_layers.iter().enumerate() {
match layer {
@@ -1225,6 +1234,7 @@ async fn apply_sensitive_payload(
if !SECRET_KEYS.contains(&secret.key.as_str())
&& !secret.key.starts_with(SSH_TUNNEL_SECRET_PREFIX)
&& !secret.key.starts_with(TRANSPORT_LAYER_SECRET_PREFIX)
&& !secret.key.starts_with(PLUGIN_CONNECTION_SECRET_PREFIX)
{
continue;
}
@@ -1264,6 +1274,7 @@ async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionCo
for key in SECRET_KEYS {
storage.delete_secret(&config.id, key).await?;
}
storage.delete_secret_prefix(&config.id, PLUGIN_CONNECTION_SECRET_PREFIX).await?;
for (index, layer) in config.transport_layers.iter().enumerate() {
match layer {
TransportLayerConfig::Ssh(_) => {
@@ -1654,7 +1665,7 @@ mod tests {
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TRUSTSTORE_PASSWORD_KEY, NACOS_AUTH_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::models::connection::{
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
@@ -1847,6 +1858,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -1934,6 +1949,10 @@ mod tests {
"password": password
}
})),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -2187,6 +2206,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -2649,6 +2672,28 @@ mod tests {
}));
}
#[tokio::test]
async fn plugin_connection_secrets_are_scrubbed_from_public_sync_metadata() {
let storage = Storage::open(&temp_db_path("plugin-public-sync")).await.unwrap();
let mut config = postgres_connection("plugin", "");
config.db_type = DatabaseType::Plugin;
config.plugin_id = Some("example.plugin".to_string());
config.plugin_connection_provider = Some("example.connection".to_string());
config.connection_secrets.insert("api_token".to_string(), "plugin-secret".to_string());
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap();
let public_json = serde_json::to_string(&snapshot.connections).unwrap();
assert!(!public_json.contains("plugin-secret"));
let encrypted = snapshot.encrypted_secrets.as_ref().expect("encrypted secrets");
let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap();
assert!(decrypted.connection_secrets.iter().any(|secret| {
secret.connection_id == "plugin"
&& secret.key == format!("{PLUGIN_CONNECTION_SECRET_PREFIX}api_token")
&& secret.secret == "plugin-secret"
}));
}
#[tokio::test]
async fn sync_restore_does_not_revive_password_when_connection_disables_saving() {
let source = Storage::open(&temp_db_path("sync-no-save-password-source")).await.unwrap();
+6 -2
View File
@@ -3835,10 +3835,10 @@ impl AppState {
PoolKind::Sqlite(_)
| PoolKind::DuckDbWorker(_)
| PoolKind::ExternalDriver { .. }
| PoolKind::PluginConnection(_)
| PoolKind::MessageQueue
| PoolKind::Nacos
| PoolKind::Consul(_) => false,
PoolKind::PluginConnection(handle) => !handle.is_running(),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => false,
}
@@ -4870,10 +4870,10 @@ impl AppState {
PoolKind::Sqlite(_)
| PoolKind::DuckDbWorker(_)
| PoolKind::ExternalDriver { .. }
| PoolKind::PluginConnection(_)
| PoolKind::MessageQueue
| PoolKind::Nacos
| PoolKind::Consul(_) => true,
PoolKind::PluginConnection(handle) => handle.is_running(),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => true,
PoolKind::Redis(redis) => match db::redis_driver::test_connection(redis).await {
@@ -6164,6 +6164,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -27,6 +27,7 @@ pub const MQTT_AUTH_PASSWORD_KEY: &str = "mqtt.auth.password";
pub const CASSANDRA_TLS_SECRET_PREFIX: &str = "cassandra.tls.";
pub const CASSANDRA_TRUSTSTORE_PASSWORD_KEY: &str = "cassandra.tls.truststore_password";
pub const CASSANDRA_KEYSTORE_PASSWORD_KEY: &str = "cassandra.tls.keystore_password";
pub const PLUGIN_CONNECTION_SECRET_PREFIX: &str = "plugin_connection.";
pub trait ConnectionSecretStore {
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>;
@@ -947,6 +948,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+1
View File
@@ -843,6 +843,7 @@ fn open_install_lock(root_dir: &Path) -> Result<File, String> {
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(root_dir.join(INSTALL_LOCK_FILE))
.map_err(|error| error.to_string())
}
@@ -242,6 +242,7 @@ impl PluginRepositoryStore {
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(self.root_dir.join(REPOSITORIES_LOCK_FILE))
.map_err(|error| error.to_string())
}
+18 -1
View File
@@ -6480,7 +6480,8 @@ mod tests {
use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType};
#[cfg(unix)]
use crate::plugins::{
InstalledPlugin, PluginDriverManifest, PluginDriverSession, PluginManifest, PluginRuntimeEnv,
InstalledPlugin, PluginCompatibility, PluginDriverManifest, PluginDriverSession, PluginManifest,
PluginRuntimeEnv,
};
use crate::storage::Storage;
@@ -6807,6 +6808,10 @@ for line in sys.stdin:
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -8598,8 +8603,10 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility { compatible: true, ..Default::default() },
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -8698,8 +8705,10 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility { compatible: true, ..Default::default() },
};
let session = Arc::new(
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
@@ -8808,8 +8817,10 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility { compatible: true, ..Default::default() },
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -8866,8 +8877,10 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility { compatible: true, ..Default::default() },
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -9327,6 +9340,10 @@ for line in sys.stdin:
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+2
View File
@@ -3640,8 +3640,10 @@ done
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..PluginManifest::default()
},
path: dir.clone(),
compatibility: crate::plugins::PluginCompatibility { compatible: true, ..Default::default() },
};
let session = std::sync::Arc::new(
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default()).await.unwrap(),
+98 -2
View File
@@ -18,7 +18,7 @@ use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TLS_SECRET_PREFIX, CASSANDRA_TRUSTSTORE_PASSWORD_KEY,
MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX,
MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY,
NACOS_AUTH_SECRET_PREFIX, NACOS_RNACOS_CONSOLE_PASSWORD_KEY,
NACOS_AUTH_SECRET_PREFIX, NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
use crate::history::{
@@ -3203,6 +3203,7 @@ fn sanitized_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
scrub_mq_token_signing_secret(&mut sanitized);
scrub_nacos_auth_secrets(&mut sanitized);
scrub_cassandra_tls_secrets(&mut sanitized);
sanitized.connection_secrets.clear();
sanitized
}
@@ -3280,9 +3281,41 @@ fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionC
persist_mq_token_signing_secret_in_tx(tx, &config)?;
persist_nacos_auth_secrets_in_tx(tx, &config)?;
persist_cassandra_tls_secrets_in_tx(tx, &config)?;
delete_secret_prefix_in_tx(tx, &config.id, PLUGIN_CONNECTION_SECRET_PREFIX)?;
for (key, secret) in &config.connection_secrets {
if !key.is_empty() {
persist_secret_in_tx(tx, &config.id, &format!("{PLUGIN_CONNECTION_SECRET_PREFIX}{key}"), secret)?;
}
}
Ok(())
}
async fn load_plugin_connection_secrets(
storage: &Storage,
connection_id: &str,
) -> Result<HashMap<String, String>, String> {
let connection_id = connection_id.to_string();
storage
.with_conn(move |conn| {
let like = format!("{PLUGIN_CONNECTION_SECRET_PREFIX}%");
let mut statement = conn
.prepare("SELECT key, secret FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2")
.map_err(|error| error.to_string())?;
let rows = statement
.query_map(params![connection_id, like], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|error| error.to_string())?;
let mut secrets = HashMap::new();
for row in rows {
let (key, secret) = row.map_err(|error| error.to_string())?;
if let Some(key) = key.strip_prefix(PLUGIN_CONNECTION_SECRET_PREFIX) {
secrets.insert(key.to_string(), secret);
}
}
Ok(secrets)
})
.await
}
fn insert_connection_copy_next_to_source(entries: &mut Vec<serde_json::Value>, source_id: &str, copy_id: &str) -> bool {
let mut index = 0;
while index < entries.len() {
@@ -3416,6 +3449,7 @@ impl Storage {
scrub_mq_token_signing_secret(&mut sanitized);
scrub_nacos_auth_secrets(&mut sanitized);
scrub_cassandra_tls_secrets(&mut sanitized);
sanitized.connection_secrets.clear();
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json])
@@ -3734,6 +3768,10 @@ impl Storage {
config.redis_sentinel_password = self.get_secret(&id, "redis_sentinel_password").await?.unwrap_or_default();
config.connection_string = self.get_secret(&id, "connection_string").await?;
config.init_script = self.get_secret(&id, "init_script").await?;
let stored_plugin_secrets = load_plugin_connection_secrets(self, &id).await?;
if !stored_plugin_secrets.is_empty() {
config.connection_secrets = stored_plugin_secrets;
}
let needs_mq_auth_rewrite = self.hydrate_mq_auth_secrets(&id, &mut config).await?;
let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?;
let needs_nacos_auth_rewrite = self.hydrate_nacos_auth_secret(&id, &mut config).await?;
@@ -4277,6 +4315,21 @@ impl Storage {
})
.await
}
pub async fn delete_secret_prefix(&self, connection_id: &str, key_prefix: &str) -> Result<(), String> {
let connection_id = connection_id.to_string();
let key_prefix = key_prefix.to_string();
self.with_conn(move |conn| {
let like = format!("{key_prefix}%");
conn.execute(
"DELETE FROM connection_secrets WHERE connection_id = ?1 AND key LIKE ?2",
params![connection_id, like],
)
.map(|_| ())
.map_err(|error| error.to_string())
})
.await
}
}
// MQ token records
@@ -5301,7 +5354,7 @@ mod tests {
use crate::connection_secrets::NACOS_RNACOS_CONSOLE_PASSWORD_KEY;
use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TRUSTSTORE_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY,
MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::history::{HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest};
use crate::models::connection::{
@@ -6030,6 +6083,41 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn save_connections_moves_plugin_secrets_to_secret_table_and_restores_them() {
let path = temp_db_path("plugin-secrets");
let storage = Storage::open(&path).await.unwrap();
let mut config = plain_connection("plugin", "");
config.db_type = DatabaseType::Plugin;
config.plugin_id = Some("example.plugin".to_string());
config.plugin_connection_provider = Some("example.connection".to_string());
config.connection_secrets.insert("api_token".to_string(), "plugin-secret".to_string());
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
let raw_json = raw_connection_json(&storage, &config.id).await;
assert!(!raw_json.contains("plugin-secret"));
assert_eq!(
storage
.get_secret(&config.id, &format!("{PLUGIN_CONNECTION_SECRET_PREFIX}api_token"))
.await
.unwrap()
.as_deref(),
Some("plugin-secret")
);
assert_eq!(storage.load_connections().await.unwrap()[0].connection_secrets, config.connection_secrets);
let mut cleared = config;
cleared.connection_secrets.clear();
storage.save_connections(std::slice::from_ref(&cleared)).await.unwrap();
assert_eq!(
storage.get_secret(&cleared.id, &format!("{PLUGIN_CONNECTION_SECRET_PREFIX}api_token")).await.unwrap(),
None
);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn switching_save_password_off_removes_stored_password() {
let path = temp_db_path("save-password-switch-off");
@@ -6108,6 +6196,10 @@ mod tests {
"token": token
}
})),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -6179,6 +6271,10 @@ mod tests {
"password": password
}
})),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -60,6 +60,10 @@ fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,