修复:避免在连接导出和日志中泄露敏感信息 / 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:
parent
9e58fefab8
commit
ac76aadbed
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export default {
|
|||
items: "{count} items",
|
||||
fields: "{count} fields",
|
||||
members: "{count} members",
|
||||
entries: "{count} entries",
|
||||
noExpiry: "no expiry",
|
||||
},
|
||||
mongo: {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export default {
|
|||
items: "{count} 个元素",
|
||||
fields: "{count} 个字段",
|
||||
members: "{count} 个成员",
|
||||
entries: "{count} 条记录",
|
||||
noExpiry: "永不过期",
|
||||
},
|
||||
mongo: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue