diff --git a/Cargo.lock b/Cargo.lock index 57bec85..7cb83ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1889,6 +1889,7 @@ version = "0.0.0" dependencies = [ "pgsq", "proxy", + "secret-resolve", "serde", "strum", ] @@ -2500,6 +2501,7 @@ dependencies = [ "rand 0.10.1", "ring", "rusqlite", + "secret-resolve", "serde", "serde_json", "serde_rusqlite", @@ -2755,6 +2757,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -6775,6 +6783,7 @@ dependencies = [ "futures-util", "reqwest 0.0.0", "russh", + "secret-resolve", "serde", "thiserror 2.0.18", "tokio", @@ -8005,6 +8014,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-resolve" +version = "0.0.0" +dependencies = [ + "dotenvy", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "secret-service" version = "3.1.0" diff --git a/Cargo.toml b/Cargo.toml index ef51e1d..cce8757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ backup = { path = "./src-crates/backup" } proxy = { path = "./src-crates/proxy" } client-store = { path = "./src-crates/client-store" } dir = { path = "./src-crates/dir" } +secret-resolve = { path = "./src-crates/secret-resolve" } whoami = "1.6.0" font-kit = "0.13.2" ring = "0.17.14" diff --git a/src-crates/connection-config/Cargo.toml b/src-crates/connection-config/Cargo.toml index bcfd316..753cdcb 100644 --- a/src-crates/connection-config/Cargo.toml +++ b/src-crates/connection-config/Cargo.toml @@ -10,3 +10,4 @@ serde = { workspace = true } strum = { workspace = true } pgsq = { path = "../pgsq" } proxy = { path = "../proxy" } +secret-resolve = { path = "../secret-resolve" } diff --git a/src-crates/connection-config/connection_config.rs b/src-crates/connection-config/connection_config.rs index 5d2fdc1..04eee3d 100644 --- a/src-crates/connection-config/connection_config.rs +++ b/src-crates/connection-config/connection_config.rs @@ -1,5 +1,6 @@ use pgsq::TlsMode; use proxy::ProxyConfig; +use secret_resolve::Secret; use serde::{Deserialize, Serialize}; use strum::{EnumProperty, IntoStaticStr}; @@ -61,6 +62,117 @@ impl ConnectionConfig { pub fn is_kv(&self) -> bool { self.get_str("kv").is_some() } + + pub async fn secret_resolve(mut self) -> Result { + match &mut self { + ConnectionConfig::SQLite(_) => {} + ConnectionConfig::SQLCipher(config) => { + config.key = Secret::resolve(&config.key).await?; + } + ConnectionConfig::PostgreSQL(config) | ConnectionConfig::CockroachDB(config) => { + config.password = Secret::resolve(&config.password).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::QuestDB(config) => { + config.password = Secret::resolve(&config.password).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::MySQL(config) | ConnectionConfig::MariaDB(config) => { + config.password = Secret::resolve_option(config.password.take()).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::ManticoreSearch(config) => { + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::MSSQL(config) => { + if let MsSqlAuthConfig::SqlServer { password, .. } = &mut config.auth { + *password = Secret::resolve(&password).await?; + } + } + ConnectionConfig::ClickHouse(config) => { + config.password = Secret::resolve(&config.password).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::ChDb(_) => {} + ConnectionConfig::Databend(config) => { + config.password = Secret::resolve(&config.password).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::BigQuery(config) => { + let BigQueryAuth::JsonKey { content } = &mut config.auth; + *content = Secret::resolve_option(content.take()).await?; + } + ConnectionConfig::Trino(config) => { + match &mut config.auth { + TrinoAuth::Password { password } => { + *password = Secret::resolve(&password).await?; + } + TrinoAuth::Jwt { token } => { + *token = Secret::resolve(&token).await?; + } + TrinoAuth::None => {} + } + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::Presto(config) => { + match &mut config.auth { + PrestoAuth::Password { password } => { + *password = Secret::resolve(&password).await?; + } + PrestoAuth::Jwt { token } => { + *token = Secret::resolve(&token).await?; + } + PrestoAuth::None => {} + } + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::Databricks(config) => { + let DatabricksAuth::Token { token } = &mut config.auth; + *token = Secret::resolve(&token).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::DuckDB(_) => {} + ConnectionConfig::Turso(config) => match &mut config.database { + TursoDatabaseConfig::Remote { token, .. } => { + *token = Secret::resolve(&token).await?; + } + TursoDatabaseConfig::Turso { encryption, .. } => { + if let Some(TursoEncryptionConfig { key, .. }) = encryption { + *key = Secret::resolve(&key).await?; + } + } + _ => {} + }, + ConnectionConfig::Rqlite(config) => { + config.password = Secret::resolve_option(config.password.take()).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::EchoLite(config) => { + config.password = Secret::resolve(&config.password).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::CloudflareD1(config) => { + config.api_token = Secret::resolve(&config.api_token).await?; + } + ConnectionConfig::WorkersAnalyticsEngine(config) => { + config.api_token = Secret::resolve(&config.api_token).await?; + } + ConnectionConfig::R2Sql(config) => { + config.api_token = Secret::resolve(&config.api_token).await?; + } + ConnectionConfig::CloudflareKv(config) => { + config.api_token = Secret::resolve(&config.api_token).await?; + } + ConnectionConfig::Redis(config) => { + config.password = Secret::resolve_option(config.password.take()).await?; + ProxyConfig::secret_resolve(&mut config.proxy).await?; + } + ConnectionConfig::S3(config) => { + config.secret_key = Secret::resolve(&config.secret_key).await?; + } + } + Ok(self) + } } // Must be consistent with the types in ConnectionConfig, but only for SQL databases diff --git a/src-crates/proxy/Cargo.toml b/src-crates/proxy/Cargo.toml index 3233ac7..4ff6d33 100644 --- a/src-crates/proxy/Cargo.toml +++ b/src-crates/proxy/Cargo.toml @@ -9,3 +9,4 @@ tokio = { workspace = true } reqwest = { path = "../reqwest" } futures-util = { workspace = true } russh = "0.61.1" +secret-resolve = { path = "../secret-resolve" } diff --git a/src-crates/proxy/src/lib.rs b/src-crates/proxy/src/lib.rs index 49553ab..9fb90a9 100644 --- a/src-crates/proxy/src/lib.rs +++ b/src-crates/proxy/src/lib.rs @@ -5,6 +5,7 @@ use futures_util::FutureExt; use reqwest::{CustomProxyConnector, CustomProxyStream}; use russh::ChannelStream; use russh::client::Msg; +use secret_resolve::Secret; use serde::{Deserialize, Serialize}; pub use ssh::{SshAuth, SshProxyConfig}; use std::net::Ipv4Addr; @@ -130,4 +131,24 @@ impl ProxyConfig { } } } + + pub async fn secret_resolve(config: &mut Option) -> Result<(), secret_resolve::Error> { + let Some(config) = config else { + return Ok(()); + }; + match config { + ProxyConfig::Ssh(ssh) => { + match &mut ssh.auth { + SshAuth::Password { password } => { + *password = Secret::resolve(&password).await?; + } + SshAuth::Key { password, .. } => { + *password = Secret::resolve_option(password.take()).await?; + } + SshAuth::Agent { .. } => {} + }; + } + } + Ok(()) + } } diff --git a/src-crates/secret-resolve/Cargo.toml b/src-crates/secret-resolve/Cargo.toml new file mode 100644 index 0000000..2196113 --- /dev/null +++ b/src-crates/secret-resolve/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "secret-resolve" +edition.workspace = true + +[lib] +path = "secret_resolve.rs" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + +[dependencies] +thiserror = { workspace = true } +tokio = { workspace = true } +dotenvy = "0.15.7" diff --git a/src-crates/secret-resolve/secret_resolve.rs b/src-crates/secret-resolve/secret_resolve.rs new file mode 100644 index 0000000..c4b9af4 --- /dev/null +++ b/src-crates/secret-resolve/secret_resolve.rs @@ -0,0 +1,335 @@ +//! # Secret Resolver Library +//! +//! This library provides a secure mechanism for resolving secrets from multiple sources: +//! - **`env:`** - Resolves environment variables or keys from `.env` files +//! - **`file:`** - Reads secrets from local files +//! - **`exec:`** - Executes shell commands to retrieve secrets +//! - **Plain text** - Returns plain text values as-is +//! +//! TODO: +//! Read from the keychain? 'keychain: service/account' +//! Read from popups or other secure input methods? 'ask: prompt message' +use std::env; +use std::io; +use std::time::Duration; +use tokio::fs; +use tokio::process::Command; +use tokio::time::timeout; + +#[cfg(target_os = "linux")] +const SHELL: (&str, &str) = ("/bin/sh", "-c"); +#[cfg(target_os = "macos")] +const SHELL: (&str, &str) = ("/bin/sh", "-c"); +#[cfg(target_os = "windows")] +const SHELL: (&str, &str) = ("cmd.exe", "/C"); + +const MAX_FILE_SIZE: u64 = 1024 * 1024; + +const EXEC_TIMEOUT: Duration = Duration::from_secs(30); + +// List of sensitive commands restricted to mitigate accidental misuse or basic security risks +// While not a bulletproof security barrier, this serves as a safeguard against common mistakes +const FORBIDDEN_COMMANDS: &'static [&'static str] = &[ + "rm", "rmdir", "dd", "mkfs", "reboot", "shutdown", "poweroff", "halt", "sudo", "su", "mount", + "umount", "chown", "chmod", "chroot", "killall", "kill", "fork", "forkbomb", + // For Windows + "del", "erase", "rd", "format", "diskpart", "runas", "taskkill", "tskill", "deltree", "fsutil", + "vssadmin", "wbadmin", "bcdedit", "takeown", "icacls", +]; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("IO error for `{path}`: {source}")] + Io { path: String, source: io::Error }, + #[error("File too large: `{path}`")] + FileTooLarge { path: String }, + + #[error("Environment variable `{name}` not found")] + EnvVarNotFound { name: String }, + #[error("Environment variable `{key}` not found in file `{path}`")] + EnvFileKeyNotFound { path: String, key: String }, + + #[error("Failed to run command: `{command}`")] + CommandFailed { command: String, source: io::Error }, + #[error("Command timed out: `{command}`")] + CommandTimedOut { command: String }, + #[error("Command produced no output: `{command}`")] + CommandNoOutput { command: String }, + #[error("Command exited with non-zero status: `{command}`, code: {code:?}, stderr: {stderr}")] + CommandNonZeroExit { + command: String, + code: Option, + stderr: String, + }, + #[error("Dangerous command not allowed: `{command}`")] + DangerousCommand { command: String }, +} + +pub struct Secret; + +impl Secret { + pub async fn resolve(secret: impl AsRef) -> Result { + Self::inner_resolve(secret.as_ref()).await + } + + pub async fn resolve_option(secret: Option>) -> Result, Error> { + match secret { + Some(s) => Self::inner_resolve(s.as_ref()).await.map(Some), + None => Ok(None), + } + } + + async fn inner_resolve(secret: &str) -> Result { + let trimmed = secret.trim_start(); + if let Some(rest) = trimmed.strip_prefix("env:") { + return Self::resolve_env(rest.trim()).await; + } + if let Some(rest) = trimmed.strip_prefix("file:") { + return Self::resolve_file(rest.trim()).await; + } + if let Some(rest) = trimmed.strip_prefix("exec:") { + return Self::resolve_exec(rest.trim()).await; + } + Ok(secret.to_string()) + } + + async fn resolve_env(rest: &str) -> Result { + let Some((path, key)) = rest.split_once('#') else { + let v = env::var(rest).map_err(|_| Error::EnvVarNotFound { + name: rest.to_string(), + })?; + return Ok(v); + }; + + let path = path.trim_end(); + let key = key.trim_start(); + + let content = fs::read(path).await.map_err(|err| Error::Io { + path: path.into(), + source: err, + })?; + let mut iter = dotenvy::from_read_iter(&content[..]); + while let Some(rst) = iter.next() { + if let Ok((k, v)) = rst { + if k == key { + return Ok(v); + } + } + } + + Err(Error::EnvFileKeyNotFound { + path: path.to_string(), + key: key.to_string(), + }) + } + + async fn resolve_file(path: &str) -> Result { + let meta = fs::metadata(path).await.map_err(|err| Error::Io { + path: path.into(), + source: err, + })?; + if meta.len() > MAX_FILE_SIZE { + return Err(Error::FileTooLarge { path: path.into() }); + } + let content = fs::read_to_string(path).await.map_err(|err| Error::Io { + path: path.into(), + source: err, + })?; + let rst = content.trim_end().to_string(); + Ok(rst) + } + + async fn resolve_exec(command: &str) -> Result { + for forbidden in FORBIDDEN_COMMANDS { + if command.starts_with(forbidden) { + let after = &command[forbidden.len()..]; + if after.is_empty() || after.chars().next().map_or(false, char::is_whitespace) { + return Err(Error::DangerousCommand { + command: command.to_string(), + }); + } + } + } + + let (shell, flag) = SHELL; + let output = Command::new(shell).arg(flag).arg(command).output(); + + let output = timeout(EXEC_TIMEOUT, output) + .await + .map_err(|_| Error::CommandTimedOut { + command: command.into(), + })? + .map_err(|e| Error::CommandFailed { + command: command.into(), + source: e, + })?; + + if !output.status.success() { + return Err(Error::CommandNonZeroExit { + command: command.into(), + code: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into(), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let rst = stdout.trim_end().to_string(); + if rst.is_empty() { + return Err(Error::CommandNoOutput { + command: command.into(), + }); + } + Ok(rst) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::fs; + + fn temp_path() -> String { + let pid = std::process::id(); + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let count = COUNTER.fetch_add(1, Ordering::SeqCst); + let path = env::temp_dir().join(format!("secret_resolve_test_{}_{}", pid, count)); + path.display().to_string() + } + + #[tokio::test] + async fn test_resolve_plain() { + assert_eq!(Secret::resolve("plain").await.unwrap(), "plain"); + assert_eq!(Secret::resolve(" plain").await.unwrap(), " plain"); + assert_eq!( + Secret::resolve(" 123456\r\n").await.unwrap(), + " 123456\r\n" + ); + assert_eq!(Secret::resolve("asd:asd").await.unwrap(), "asd:asd"); + assert_eq!(Secret::resolve("file :asd").await.unwrap(), "file :asd"); + } + + #[tokio::test] + async fn test_resolve_option() { + assert_eq!(Secret::resolve("123").await.unwrap(), "123"); + assert_eq!( + Secret::resolve_option(Some("123".to_string())) + .await + .unwrap(), + Some("123".to_string()) + ); + assert_eq!(Secret::resolve_option(None::).await.unwrap(), None); + } + + #[tokio::test] + async fn test_resolve_empty() { + // TODO + // let result = Secret::resolve("env:").await.unwrap(); + // let result = Secret::resolve("env:#TOKEN").await.unwrap(); + // let result = Secret::resolve("file:").await.unwrap(); + // let result = Secret::resolve("exec:").await.unwrap(); + } + + #[tokio::test] + async fn test_resolve_env_var() { + unsafe { env::set_var("TEST_SECRET_VAR", "secret_value") }; + + let result = Secret::resolve("env:TEST_SECRET_VAR").await.unwrap(); + assert_eq!(result, "secret_value"); + + let result = Secret::resolve(" env: TEST_SECRET_VAR ").await.unwrap(); + assert_eq!(result, "secret_value"); + + let result = Secret::resolve(" env: NONEXISTENT ").await; + assert!(matches!(result, Err(Error::EnvVarNotFound { .. }))); + } + + #[tokio::test] + async fn test_resolve_env_file() { + let path = temp_path(); + fs::write( + &path, + r#" + DB_PASSWORD=file_secret + OTHER=value + "#, + ) + .await + .unwrap(); + + let result = Secret::resolve(&format!("env:{}#DB_PASSWORD", path)) + .await + .unwrap(); + assert_eq!(result, "file_secret"); + + let result = Secret::resolve(&format!("env: {} # OTHER ", path)) + .await + .unwrap(); + assert_eq!(result, "value"); + + let result = Secret::resolve(&format!("env:{}#NONEXISTENT", path)).await; + assert!(matches!(result, Err(Error::EnvFileKeyNotFound { .. }))); + + fs::remove_file(&path).await.unwrap(); + } + + #[tokio::test] + async fn test_resolve_file() { + let path = temp_path(); + fs::write(&path, "my_file_secret\n").await.unwrap(); + + let result = Secret::resolve(&format!("file:{}", path)).await.unwrap(); + assert_eq!(result, "my_file_secret"); + + let result = Secret::resolve(&format!("file: {}\n", path)) + .await + .unwrap(); + assert_eq!(result, "my_file_secret"); + + let result = Secret::resolve("file:/nonexistent/path/file.txt").await; + assert!(matches!(result, Err(Error::Io { .. }))); + + fs::remove_file(&path).await.unwrap(); + } + + #[tokio::test] + async fn test_resolve_exec() { + let result = Secret::resolve("exec:echo exec_secret").await.unwrap(); + assert_eq!(result, "exec_secret"); + + let result = Secret::resolve("exec:echo $HOME").await.unwrap(); + assert!(!result.is_empty()); + + let result = Secret::resolve("exec:printf 'hello\\n'").await.unwrap(); + assert_eq!(result, "hello"); + + let result = Secret::resolve("exec:echo 'hello world' | awk '{print $2}'") + .await + .unwrap(); + assert_eq!(result, "world"); + } + + #[tokio::test] + async fn test_resolve_exec_non_zero_exit() { + let result = Secret::resolve("exec:exit 1").await; + assert!(matches!(result, Err(Error::CommandNonZeroExit { .. }))); + + let result = Secret::resolve("exec:nonexistent_command").await; + assert!(matches!(result, Err(Error::CommandNonZeroExit { .. }))); + } + + #[tokio::test] + async fn test_resolve_exec_no_output() { + let result = Secret::resolve("exec: sleep 1").await; + assert!(matches!(result, Err(Error::CommandNoOutput { .. }))); + + let result = Secret::resolve("exec: echo ''").await; + assert!(matches!(result, Err(Error::CommandNoOutput { .. }))); + } + + // #[tokio::test] + // async fn test_resolve_exec_timeout() { + // let result = Secret::resolve("exec:sleep 10").await; + // assert!(matches!(result, Err(Error::CommandTimedOut { .. }))); + // } +} diff --git a/src-tauri/database.rs b/src-tauri/database.rs index 33ef4e7..19af7fe 100644 --- a/src-tauri/database.rs +++ b/src-tauri/database.rs @@ -23,6 +23,8 @@ pub enum Error { Io(#[from] std::io::Error), #[error(transparent)] Fbon(#[from] fbon::SerError), + #[error("Secret resolve error: {0}")] + SecretResolve(#[from] secret_resolve::Error), } type Result = std::result::Result; @@ -38,6 +40,7 @@ impl Serialize for Error { #[command] pub async fn test(config: ConnectionConfig) -> Result> { + let config = config.secret_resolve().await?; let version = Database::test(config).await?; Ok(version) } @@ -48,6 +51,7 @@ pub async fn connect( window: Window, config: ConnectionConfig, ) -> Result<()> { + let config = config.secret_resolve().await?; store.connect(window.label().into(), config).await } diff --git a/src-web/i18n/translation.ts b/src-web/i18n/translation.ts index c509fac..5594f45 100644 --- a/src-web/i18n/translation.ts +++ b/src-web/i18n/translation.ts @@ -301,6 +301,17 @@ export const translationText = { [Language.zhCN]: `显示密码`, [Language.ja]: 'パスワードを表示' }, + secretResolveMsg: { + [Language.en]: + 'To let Dataflare load passwords or credentials from external sources, please use the following specific formats:', + [Language.de]: + 'Um Dataflare das Laden von Passwörtern oder Anmeldeinformationen aus externen Quellen zu ermöglichen, verwenden Sie bitte die folgenden spezifischen Formate:', + [Language.frFR]: + 'Pour permettre à Dataflare de charger les mots de passe ou les identifiants à partir de sources externes, veuillez utiliser les formats spécifiques suivants :', + [Language.zhCN]: '若要让 Dataflare 从外部加载密码或凭证,请使用以下特定格式:', + [Language.ja]: + 'Dataflareが外部ソースからパスワードや資格情報をロードできるようにするには、次の特定の形式を使用してください:' + }, showSuggestions: { [Language.en]: `Show suggestions`, [Language.de]: 'Zeige Vorschläge', diff --git a/src-web/pages/connections/from.tsx b/src-web/pages/connections/from.tsx index 0a95469..5dbd5ed 100644 --- a/src-web/pages/connections/from.tsx +++ b/src-web/pages/connections/from.tsx @@ -46,6 +46,7 @@ export const Item = ({ placeholder={placeholder} value={value} onChange={(val) => onChange(val)} + secretResolve /> ) : ( void + secretResolve?: boolean } -export const PasswordInput = ({ className, value, placeholder, onChange }: PasswordInputProps) => { +export const PasswordInput = ({ + className, + value, + placeholder, + onChange, + secretResolve = false +}: PasswordInputProps) => { const { t } = useTranslation() const [show, setShow] = useState(false) return ( -
- e.stopPropagation()} - spellCheck='false' - autoComplete='off' - autoCapitalize='none' - value={value} - onChange={(e) => onChange(e.target.value)} - /> - setShow(!show)} - > - {show ? : } - +
+
+ e.stopPropagation()} + spellCheck='false' + autoComplete='off' + autoCapitalize='none' + value={value} + onChange={(e) => onChange(e.target.value)} + /> + setShow(!show)} + > + {show ? : } + +
+ {secretResolve && }
) } +const PasswordSecretResolve = () => { + const { t } = useTranslation() + const items = [ + { + label: 'From env:', + example: 'env: MY_PASSWORD' + }, + { + label: 'From env file:', + example: 'env: /path/.env#MY_PASSWORD' + }, + { + label: 'From file content:', + example: 'file: /path/file' + }, + { + label: 'From shell output:', + example: "exec: echo 'MY_PASSWORD'" + } + ] + return ( + } + side='left' + > +
+
{t('secretResolveMsg')}
+ {items.map((item) => { + return ( + + + {item.label} + + + + ) + })} +
+
+ ) +} + export interface SuggestionInputProps { className?: string placeholder?: string