修复:避免在连接导出和日志中泄露敏感信息 / avoid leaking connection credentials in exports and logs (#16)

* fix: redact ssh password from connection exports

* test: cover redacted connection targets

* fix: avoid logging connection credentials

* fix(redis): support stream value preview
This commit is contained in:
Sugar 2026-04-30 15:44:53 +08:00 committed by GitHub
parent 9e58fefab8
commit ac76aadbed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 266 additions and 18 deletions

View File

@ -179,7 +179,11 @@ pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, S
#[tauri::command]
pub async fn test_connection(config: ConnectionConfig) -> Result<String, String> {
let url = config.connection_url();
log::info!("[test_connection] db_type={:?} url={}", config.db_type, &url[..url.len().min(80)]);
log::info!(
"[test_connection] db_type={:?} target={}",
config.db_type,
config.redacted_connection_url()
);
match config.db_type {
DatabaseType::Mysql => {
let pool = db::mysql::connect(&url).await?;

View File

@ -1,6 +1,8 @@
use redis::AsyncCommands;
use redis::{AsyncCommands, Value as RedisRawValue};
use serde::{Deserialize, Serialize};
const STREAM_ENTRY_LIMIT: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisKeyInfo {
pub key: String,
@ -40,7 +42,9 @@ pub async fn connect(url: &str) -> Result<redis::aio::MultiplexedConnection, Str
Ok(con)
}
pub async fn list_databases(con: &mut redis::aio::MultiplexedConnection) -> Result<Vec<u32>, String> {
pub async fn list_databases(
con: &mut redis::aio::MultiplexedConnection,
) -> Result<Vec<u32>, String> {
let info: String = redis::cmd("INFO")
.arg("keyspace")
.query_async(con)
@ -139,7 +143,10 @@ pub async fn get_value(
.zrange_withscores(key, 0, -1)
.await
.map_err(|e| e.to_string())?;
serde_json::json!(v.iter().map(|(m, s)| serde_json::json!({"member": m, "score": s})).collect::<Vec<_>>())
serde_json::json!(v
.iter()
.map(|(m, s)| serde_json::json!({"member": m, "score": s}))
.collect::<Vec<_>>())
}
"hash" => {
let v: Vec<(String, String)> = con.hgetall(key).await.map_err(|e| e.to_string())?;
@ -149,6 +156,7 @@ pub async fn get_value(
.collect();
serde_json::Value::Object(map)
}
"stream" => get_stream_entries(con, key).await?,
_ => serde_json::Value::Null,
};
@ -160,6 +168,75 @@ pub async fn get_value(
})
}
async fn get_stream_entries(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
) -> Result<serde_json::Value, String> {
let raw: RedisRawValue = redis::cmd("XRANGE")
.arg(key)
.arg("-")
.arg("+")
.arg("COUNT")
.arg(STREAM_ENTRY_LIMIT)
.query_async(con)
.await
.map_err(|e| e.to_string())?;
Ok(parse_stream_entries(raw))
}
fn parse_stream_entries(raw: RedisRawValue) -> serde_json::Value {
match raw {
RedisRawValue::Array(entries) => {
serde_json::Value::Array(entries.into_iter().filter_map(parse_stream_entry).collect())
}
_ => serde_json::Value::Null,
}
}
fn parse_stream_entry(entry: RedisRawValue) -> Option<serde_json::Value> {
let mut parts = match entry {
RedisRawValue::Array(parts) if parts.len() == 2 => parts.into_iter(),
_ => return None,
};
let id = redis_value_to_string(parts.next()?)?;
let fields = match parts.next()? {
RedisRawValue::Array(fields) => fields,
_ => return None,
};
let mut field_map = serde_json::Map::new();
let mut fields = fields.into_iter();
while let Some(field) = fields.next() {
let Some(value) = fields.next() else {
break;
};
if let Some(field_name) = redis_value_to_string(field) {
let value = redis_value_to_string(value).unwrap_or_default();
field_map.insert(field_name, serde_json::Value::String(value));
}
}
Some(serde_json::json!({
"id": id,
"fields": field_map,
}))
}
fn redis_value_to_string(value: RedisRawValue) -> Option<String> {
match value {
RedisRawValue::BulkString(bytes) => Some(String::from_utf8_lossy(&bytes).to_string()),
RedisRawValue::SimpleString(value) => Some(value),
RedisRawValue::Int(value) => Some(value.to_string()),
RedisRawValue::Double(value) => Some(value.to_string()),
RedisRawValue::Boolean(value) => Some(value.to_string()),
RedisRawValue::VerbatimString { text, .. } => Some(text),
RedisRawValue::Okay => Some("OK".to_string()),
_ => None,
}
}
pub async fn set_string(
con: &mut redis::aio::MultiplexedConnection,
key: &str,
@ -254,3 +331,65 @@ pub async fn set_remove(
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::{parse_stream_entries, RedisRawValue};
fn bulk(value: &str) -> RedisRawValue {
RedisRawValue::BulkString(value.as_bytes().to_vec())
}
#[test]
fn parses_stream_entries() {
let raw = RedisRawValue::Array(vec![RedisRawValue::Array(vec![
bulk("1714470000000-0"),
RedisRawValue::Array(vec![
bulk("event"),
bulk("login"),
bulk("user_id"),
bulk("42"),
]),
])]);
let parsed = parse_stream_entries(raw);
assert_eq!(
parsed,
serde_json::json!([
{
"id": "1714470000000-0",
"fields": {
"event": "login",
"user_id": "42"
}
}
])
);
}
#[test]
fn skips_malformed_stream_entries() {
let raw = RedisRawValue::Array(vec![
RedisRawValue::Array(vec![bulk("1714470000000-0")]),
RedisRawValue::Array(vec![
bulk("1714470000001-0"),
RedisRawValue::Array(vec![bulk("event"), bulk("logout")]),
]),
]);
let parsed = parse_stream_entries(raw);
assert_eq!(
parsed,
serde_json::json!([
{
"id": "1714470000001-0",
"fields": {
"event": "logout"
}
}
])
);
}
}

View File

@ -35,7 +35,9 @@ pub struct ConnectionConfig {
pub ssl: bool,
}
fn default_ssh_port() -> u16 { 22 }
fn default_ssh_port() -> u16 {
22
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
@ -61,6 +63,46 @@ impl ConnectionConfig {
self.connection_url_with_host(&self.host, self.port)
}
pub fn redacted_connection_url(&self) -> String {
self.redacted_connection_url_with_host(&self.host, self.port)
}
pub fn redacted_connection_url_with_host(&self, host: &str, port: u16) -> String {
let db_part = self
.database
.as_deref()
.filter(|d| !d.is_empty())
.map(|d| format!("/{}", encode_url_part(d)))
.unwrap_or_default();
let params = self.normalized_url_params();
match self.db_type {
DatabaseType::Sqlite | DatabaseType::DuckDb => {
format!("{}?mode=rwc", self.host)
}
DatabaseType::Redis => {
let scheme = if self.ssl { "rediss" } else { "redis" };
format!("{scheme}://{host}:{port}/")
}
DatabaseType::Mysql => format!("mysql://{host}:{port}{db_part}?{params}"),
DatabaseType::Postgres => {
let suffix = if params.is_empty() {
String::new()
} else {
format!("?{params}")
};
format!("postgres://{host}:{port}{db_part}{suffix}")
}
DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"),
DatabaseType::SqlServer => format!(
"server=tcp:{host},{port};database={}",
self.database.as_deref().unwrap_or("master")
),
DatabaseType::MongoDb => format!("mongodb://{host}:{port}{db_part}"),
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
}
}
pub fn connection_url_with_host(&self, host: &str, port: u16) -> String {
let db_part = self
.database
@ -86,17 +128,27 @@ impl ConnectionConfig {
format!("{scheme}://{username}:{password}@{host}:{port}/")
}
}
DatabaseType::Mysql => format!("mysql://{}:{}@{host}:{port}{db_part}?{params}", username, password),
DatabaseType::Postgres => {
let suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password)
}
DatabaseType::ClickHouse => format!(
"http://{host}:{port}{db_part}"
DatabaseType::Mysql => format!(
"mysql://{}:{}@{host}:{port}{db_part}?{params}",
username, password
),
DatabaseType::Postgres => {
let suffix = if params.is_empty() {
String::new()
} else {
format!("?{params}")
};
format!(
"postgres://{}:{}@{host}:{port}{db_part}{suffix}",
username, password
)
}
DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"),
DatabaseType::SqlServer => format!(
"server=tcp:{host},{port};user={};password={};database={}",
self.username, self.password, self.database.as_deref().unwrap_or("master")
self.username,
self.password,
self.database.as_deref().unwrap_or("master")
),
DatabaseType::MongoDb => {
if self.username.is_empty() {
@ -105,10 +157,9 @@ impl ConnectionConfig {
format!("mongodb://{username}:{password}@{host}:{port}{db_part}")
}
}
DatabaseType::Oracle => format!(
"oracle://{}:{}@{host}:{port}{db_part}",
username, password
),
DatabaseType::Oracle => {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
}
}
@ -204,4 +255,41 @@ mod tests {
"postgres://postgres:secret@10.1.2.3:2883/test?sslmode=disable"
);
}
#[test]
fn redacted_mysql_url_omits_credentials() {
let config = mysql_config("user@tenant#cluster", "p@ss:word#1", Some("db/name"));
let url = config.redacted_connection_url();
assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred");
assert!(!url.contains("user"));
assert!(!url.contains("p%40ss"));
assert!(!url.contains("p@ss"));
}
#[test]
fn redacted_sqlserver_url_omits_credentials() {
let mut config = mysql_config("sa", "super-secret", Some("master"));
config.db_type = DatabaseType::SqlServer;
let url = config.redacted_connection_url();
assert_eq!(url, "server=tcp:10.1.2.3,2883;database=master");
assert!(!url.contains("sa"));
assert!(!url.contains("super-secret"));
}
#[test]
fn redacted_redis_url_omits_credentials_and_keeps_tls_scheme() {
let mut config = mysql_config("default", "redis-secret", None);
config.db_type = DatabaseType::Redis;
config.ssl = true;
let url = config.redacted_connection_url();
assert_eq!(url, "rediss://10.1.2.3:2883/");
assert!(!url.contains("default"));
assert!(!url.contains("redis-secret"));
}
}

View File

@ -73,6 +73,7 @@ function typeColor(type: string): string {
case "set": return "text-purple-500";
case "zset": return "text-amber-500";
case "hash": return "text-orange-500";
case "stream": return "text-teal-500";
default: return "text-muted-foreground";
}
}

View File

@ -234,6 +234,20 @@ onMounted(load);
</div>
</div>
<!-- Stream (readonly) -->
<div v-else-if="data.key_type === 'stream'" class="flex-1 overflow-auto">
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
{{ t('redis.entries', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
</div>
<div v-for="entry in data.value" :key="entry.id" class="px-4 py-2 border-b text-sm font-mono hover:bg-accent/50">
<div class="mb-1 text-xs text-muted-foreground">{{ entry.id }}</div>
<div v-for="(val, field) in entry.fields" :key="String(field)" class="grid grid-cols-[minmax(6rem,0.35fr)_1fr] gap-3 py-0.5">
<span class="truncate text-blue-500">{{ field }}</span>
<span class="truncate text-muted-foreground">{{ val }}</span>
</div>
</div>
</div>
<!-- Unknown -->
<div v-else class="flex-1 overflow-auto p-4">
<pre class="font-mono text-sm whitespace-pre-wrap">{{ formatValue(data.value) }}</pre>

View File

@ -173,6 +173,7 @@ export default {
items: "{count} items",
fields: "{count} fields",
members: "{count} members",
entries: "{count} entries",
noExpiry: "no expiry",
},
mongo: {

View File

@ -171,6 +171,7 @@ export default {
items: "{count} 个元素",
fields: "{count} 个字段",
members: "{count} 个成员",
entries: "{count} 条记录",
noExpiry: "永不过期",
},
mongo: {

View File

@ -459,7 +459,7 @@ export const useConnectionStore = defineStore("connection", () => {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
if (!path) return;
const data = connections.value.map((c) => ({ ...c, password: "" }));
const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "" }));
await writeTextFile(path, JSON.stringify(data, null, 2));
}