diff --git a/.gitignore b/.gitignore index a11181a73..74638440c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ releases-*.json test.pdb portable/ DBX_*_x64-portable.zip +.agents/skills \ No newline at end of file diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index a57d7f4bd..0c9842b8c 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -142,6 +142,7 @@ const defaultForm = (): ConnectionForm => ({ redis_sentinel_tls: false, redis_cluster_nodes: "", etcd_endpoints: "", + read_only: false, }); function defaultSshTunnel(): SshTunnelConfig { @@ -573,6 +574,7 @@ watch( redis_sentinel_tls: config.redis_sentinel_tls || false, redis_cluster_nodes: config.redis_cluster_nodes || "", etcd_endpoints: config.etcd_endpoints || "", + read_only: config.read_only || false, }; h2ConnectionMode.value = h2ConnectionModeForConfig(config); customColorInput.value = config.color || ""; @@ -1069,6 +1071,7 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { const idleTimeout = Number(config.idle_timeout_secs); config.idle_timeout_secs = Number.isFinite(idleTimeout) && idleTimeout >= 0 ? idleTimeout : 60; if (!config.one_time) config.one_time = undefined; + if (!config.read_only) config.read_only = undefined; if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; } else if (config.db_type === "mongodb") { @@ -3130,6 +3133,13 @@ function openExternalUrl(url: string) { class="col-span-3" /> +
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d83e0a8d9..b2a7effaa 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -274,6 +274,8 @@ export default { connectTimeout: "Connection Timeout (seconds)", queryTimeout: "Query Timeout (seconds)", idleTimeout: "Idle Timeout (seconds)", + readOnly: "Read Only", + readOnlyHint: "Block all write operations (INSERT, UPDATE, DELETE, etc.)", proxy: "Proxy", proxyEnable: "Connect database through proxy", proxyType: "Proxy Type", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d6602ba70..688ab1079 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -255,6 +255,8 @@ export default { connectTimeout: "Tiempo de espera de conexión (segundos)", queryTimeout: "Tiempo de espera de consulta (segundos)", idleTimeout: "Tiempo de espera inactivo (segundos)", + readOnly: "Solo lectura", + readOnlyHint: "Bloquear todas las operaciones de escritura (INSERT, UPDATE, DELETE, etc.)", proxy: "Proxy", proxyEnable: "Conectar la base de datos mediante proxy", proxyType: "Tipo de proxy", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index abc7a01f9..b7cb305ff 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -266,6 +266,8 @@ export default { connectTimeout: "Timeout Connessione (secondi)", queryTimeout: "Timeout Query (secondi)", idleTimeout: "Timeout Inattività (secondi)", + readOnly: "Sola lettura", + readOnlyHint: "Blocca tutte le operazioni di scrittura (INSERT, UPDATE, DELETE, ecc.)", proxy: "Proxy", proxyEnable: "Connetti database tramite proxy", proxyType: "Tipo Proxy", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d17995208..756917078 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -265,6 +265,8 @@ export default { connectTimeout: "Timeout de Conexão (segundos)", queryTimeout: "Timeout de Consulta (segundos)", idleTimeout: "Timeout de Inatividade (segundos)", + readOnly: "Somente leitura", + readOnlyHint: "Bloquear todas as operações de escrita (INSERT, UPDATE, DELETE, etc.)", proxy: "Proxy", proxyEnable: "Conectar ao banco de dados via proxy", proxyType: "Tipo de Proxy", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9fc7f8c65..6f55d8e5d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -270,6 +270,8 @@ export default { connectTimeout: "连接超时(秒)", queryTimeout: "查询超时(秒)", idleTimeout: "空闲超时(秒)", + readOnly: "只读模式", + readOnlyHint: "阻止所有写操作(INSERT、UPDATE、DELETE 等)", proxy: "代理", proxyEnable: "通过代理连接数据库", proxyType: "代理类型", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 07384b03d..cc3e945bd 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -259,6 +259,8 @@ export default { connectTimeout: "連線逾時(秒)", queryTimeout: "查詢逾時(秒)", idleTimeout: "閒置逾時(秒)", + readOnly: "唯讀模式", + readOnlyHint: "阻止所有寫入操作(INSERT、UPDATE、DELETE 等)", proxy: "代理伺服器", proxyEnable: "藉由代理伺服器連線資料庫", proxyType: "代理伺服器類型", diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 5656c1a73..caabefbbb 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -94,6 +94,7 @@ export interface ConnectionConfig { redis_cluster_nodes?: string; etcd_endpoints?: string; one_time?: boolean; + read_only?: boolean; } export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig); diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index bc80963fc..940a1f69f 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -415,6 +415,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 44877a2ff..b624b733a 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -596,6 +596,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, }; scrub_connection_secrets(&mut config); assert!(config.password.is_empty()); diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 2632f1db5..f0d479113 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1210,6 +1210,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index d8d897dc0..a807907b4 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -511,6 +511,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 6d4327e45..e29dbdbc6 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -71,6 +71,8 @@ pub struct ConnectionConfig { pub jdbc_driver_paths: Vec, #[serde(default, skip_serializing_if = "is_false")] pub one_time: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub read_only: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -341,6 +343,8 @@ struct ConnectionConfigData { pub jdbc_driver_paths: Vec, #[serde(default)] pub one_time: bool, + #[serde(default)] + pub read_only: bool, } impl From for ConnectionConfig { @@ -383,6 +387,7 @@ impl From for ConnectionConfig { jdbc_driver_class: data.jdbc_driver_class, jdbc_driver_paths: data.jdbc_driver_paths, one_time: data.one_time, + read_only: data.read_only, } } } @@ -1349,6 +1354,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 0b94750bd..1693e0ec9 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -15,6 +15,47 @@ pub const QUERY_TIMEOUT: Duration = Duration::from_secs(30); pub const MAX_ROWS: usize = 10000; pub const QUERY_CANCELED: &str = "Query canceled"; +/// Check read-only protection for a connection, blocking write SQL statements. +/// Only clones the connection name when read-only mode is active, avoiding +/// unnecessary allocations otherwise. +/// Uses config_for_pool_key to correctly resolve configs when pool_key includes +/// a database suffix (e.g., "prod:app" → config stored under "prod"). +pub async fn check_read_only_for_connection(state: &AppState, pool_key: &str, sql: &str) -> Result<(), String> { + let conn_name = { + let configs = state.configs.read().await; + crate::connection::config_for_pool_key(pool_key, &configs).filter(|c| c.read_only).map(|c| c.name.clone()) + }; + if let Some(name) = conn_name { + crate::query_execution_sql::check_read_only(sql, &name)?; + } + Ok(()) +} + +/// Check read-only protection for a connection across multiple SQL statements. +pub async fn check_read_only_for_connection_multi( + state: &AppState, + pool_key: &str, + statements: &[impl AsRef], +) -> Result<(), String> { + let conn_name = { + let configs = state.configs.read().await; + crate::connection::config_for_pool_key(pool_key, &configs).filter(|c| c.read_only).map(|c| c.name.clone()) + }; + if let Some(name) = conn_name { + for sql in statements { + crate::query_execution_sql::check_read_only(sql.as_ref(), &name)?; + } + } + Ok(()) +} + +/// Check whether a connection has read-only mode enabled, returning the connection name if so. +/// This uses connection_id directly (not pool_key), so it is safe to call at command entry points +/// before any pool key is constructed. +pub async fn connection_readonly_name(state: &AppState, connection_id: &str) -> Option { + state.configs.read().await.get(connection_id).filter(|c| c.read_only).map(|c| c.name.clone()) +} + async fn connection_is_mongodb(state: &AppState, connection_id: &str) -> bool { let configs = state.configs.read().await; configs.get(connection_id).is_some_and(|config| config.db_type == DatabaseType::MongoDb) @@ -561,13 +602,18 @@ pub async fn do_execute( options: QueryExecutionOptions, ) -> Result { let query_timeout = resolve_query_timeout(options.timeout_secs); - let duckdb_attached_names = state - .configs - .read() - .await - .get(pool_key) - .map(|config| config.attached_databases.iter().map(|database| database.name.clone()).collect::>()) - .unwrap_or_default(); + let (duckdb_attached_names, conn_name_if_readonly) = { + let configs = state.configs.read().await; + let config = crate::connection::config_for_pool_key(pool_key, &configs); + let attached = config + .map(|c| c.attached_databases.iter().map(|db| db.name.clone()).collect::>()) + .unwrap_or_default(); + let conn_name = config.filter(|c| c.read_only).map(|c| c.name.clone()); + (attached, conn_name) + }; + if let Some(name) = conn_name_if_readonly { + crate::query_execution_sql::check_read_only(sql, &name)?; + } let pool_db_type = connection_database_type_for_pool_key(state, pool_key).await; let connections = state.connections.read().await; let pool = connections.get(pool_key).ok_or("Connection not found")?; @@ -955,6 +1001,8 @@ pub async fn execute_multi_core_with_options( } if let Some((pool, mode)) = mysql_pool { + // Read-only check for MySQL batch path + check_read_only_for_connection_multi(state, &pool_key, &statements).await?; let mysql_dialect = connection_mysql_query_dialect(state, connection_id).await; return execute_multi_mysql(&pool, mode, mysql_dialect, &statements, cancel_token, options).await; } @@ -1045,6 +1093,9 @@ async fn execute_multi_sqlserver( options: QueryExecutionOptions, ) -> Result, String> { let batches = split_sql_batches(sql); + + // Read-only check for SQL Server batch path + check_read_only_for_connection_multi(state, pool_key, &batches).await?; let mut all_results = Vec::new(); let max_rows = options.max_rows; @@ -1194,6 +1245,9 @@ pub async fn execute_statements_in_transaction( state.get_or_create_pool(connection_id, Some(database)).await? }; + // Read-only check: intercept all transaction paths before dispatching + check_read_only_for_connection_multi(state, &pool_key, statements).await?; + let start = std::time::Instant::now(); // Clone the pool handle within the lock, then drop it before any async work. @@ -1749,6 +1803,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, }; let params = external_driver_query_params( diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs index a4ba2bd2e..9df9c43d2 100644 --- a/crates/dbx-core/src/query_execution_sql.rs +++ b/crates/dbx-core/src/query_execution_sql.rs @@ -97,13 +97,113 @@ fn is_safe_explain_source(sql: &str) -> bool { }) } -fn contains_dangerous_sql_keyword(sql: &str) -> bool { +pub fn contains_dangerous_sql_keyword(sql: &str) -> bool { let source = strip_sql_comments_and_literals(sql).to_lowercase(); ["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"] .iter() .any(|keyword| contains_word(&source, keyword)) } +/// Keywords that start a read-only SQL statement. +/// Note: FROM is a DuckDB-specific read keyword supporting SELECT-less FROM syntax +/// (e.g. `FROM table SELECT *`). In other databases, a statement starting with FROM +/// is invalid and would be rejected by the database itself, so allowing it poses no risk. +/// +/// PRAGMA is intentionally NOT in this list because some PRAGMA statements modify +/// database or session state (e.g. SQLite `PRAGMA journal_mode=WAL`). Instead, +/// read-only PRAGMA forms are handled separately in `is_safe_read_pragma`. +const READ_SQL_KEYWORDS: &[&str] = &["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "FROM"]; + +/// PRAGMA names that are known to be safe read-only queries in SQLite/DuckDB. +/// Only the function-call form `PRAGMA name(args)` matching these names is allowed. +/// Any PRAGMA with assignment (`PRAGMA name = value`) or not in this list is blocked. +const SAFE_READ_PRAGMA_NAMES: &[&str] = &[ + "TABLE_INFO", + "TABLE_XINFO", + "INDEX_LIST", + "INDEX_INFO", + "FOREIGN_KEY_LIST", + "DATABASE_LIST", + "COMPILE_OPTIONS", + "DATA_VERSION", +]; + +/// Returns true if the SQL statement is a write operation (not a pure read). +pub fn is_write_sql(sql: &str) -> bool { + // 1. Strip comments and string literals + let cleaned = strip_sql_comments_and_literals(sql); + let trimmed = cleaned.trim_start(); + if trimmed.is_empty() { + return false; + } + let upper = trimmed.to_uppercase(); + + // 2. Check if first keyword is a read keyword + let starts_with_read = READ_SQL_KEYWORDS.iter().any(|kw| { + upper.starts_with(kw) && (upper.len() == kw.len() || !upper.as_bytes()[kw.len()].is_ascii_alphanumeric()) + }); + + // 3. Special handling for PRAGMA: only allow safe read-only forms + if !starts_with_read && starts_with_keyword(&upper, "PRAGMA") { + return !is_safe_read_pragma(&upper); + } + + // A statement is a write if it doesn't start with a read keyword, + // or if it contains embedded dangerous keywords (e.g. CTE-wrapped writes like WITH ... AS (DELETE FROM ...)) + !starts_with_read || contains_dangerous_sql_keyword(sql) +} + +/// Check if a PRAGMA statement is a safe read-only form. +/// Allows: PRAGMA table_info(...), PRAGMA index_list(...), etc. +/// Blocks: PRAGMA name = value, PRAGMA name(value), or unknown PRAGMA names. +fn is_safe_read_pragma(upper_stripped: &str) -> bool { + // Skip "PRAGMA" keyword to get the rest + let rest = upper_stripped.strip_prefix("PRAGMA").unwrap_or("").trim_start(); + + if rest.is_empty() { + return false; + } + + // Extract the pragma name (first word) + let name_end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()); + let pragma_name = &rest[..name_end]; + + // Check if it's in the safe list + if !SAFE_READ_PRAGMA_NAMES.iter().any(|&safe| pragma_name == safe) { + return false; + } + + // Check the form after the name: must be function-call style "(...)" or end of statement + let after_name = rest[name_end..].trim_start(); + if after_name.is_empty() { + // PRAGMA table_info (no args) — safe + return true; + } + if after_name.starts_with('(') { + // PRAGMA table_info(users) — safe read form + return true; + } + // PRAGMA table_info = something or other unsafe form — blocked + false +} + +fn starts_with_keyword(upper: &str, keyword: &str) -> bool { + upper.starts_with(keyword) + && (upper.len() == keyword.len() || !upper.as_bytes()[keyword.len()].is_ascii_alphanumeric()) +} + +/// Check whether a SQL statement is allowed under read-only mode. +/// Returns Err with a descriptive message if the statement is a write operation. +pub fn check_read_only(sql: &str, connection_name: &str) -> Result<(), String> { + if is_write_sql(sql) { + return Err(format!( + "Read-only mode: connection '{}' has read-only protection enabled. Write operation (including stored procedure calls) blocked.", + connection_name + )); + } + Ok(()) +} + fn contains_word(source: &str, word: &str) -> bool { let bytes = source.as_bytes(); let word_bytes = word.as_bytes(); @@ -178,7 +278,7 @@ fn strip_sql_comments(sql: &str) -> String { output } -fn strip_sql_comments_and_literals(sql: &str) -> String { +pub fn strip_sql_comments_and_literals(sql: &str) -> String { let mut output = String::with_capacity(sql.len()); let mut chars = sql.chars().peekable(); let mut in_line_comment = false; @@ -354,4 +454,195 @@ mod tests { None ); } + + #[test] + fn strip_sql_comments_and_literals_basic() { + assert_eq!(strip_sql_comments_and_literals("SELECT 1"), "SELECT 1"); + assert_eq!(strip_sql_comments_and_literals("SELECT 'hello'"), "SELECT "); + assert_eq!(strip_sql_comments_and_literals("SELECT \"hello\""), "SELECT "); + assert_eq!(strip_sql_comments_and_literals("-- comment\nSELECT 1"), " SELECT 1"); + assert_eq!(strip_sql_comments_and_literals("/* block */ SELECT 1"), " SELECT 1"); + assert_eq!(strip_sql_comments_and_literals("# comment\nSELECT 1"), " SELECT 1"); + } + + #[test] + fn strip_sql_comments_and_literals_nested() { + // String literals containing comments should be stripped + assert_eq!(strip_sql_comments_and_literals("SELECT '/* not a comment */'"), "SELECT "); + // Comments containing string delimiters should be stripped + assert_eq!(strip_sql_comments_and_literals("/* 'not a string' */ SELECT 1"), " SELECT 1"); + } + + #[test] + fn contains_dangerous_sql_keyword_detects_writes() { + assert!(contains_dangerous_sql_keyword("DROP TABLE users")); + assert!(contains_dangerous_sql_keyword("DELETE FROM users")); + assert!(contains_dangerous_sql_keyword("TRUNCATE TABLE users")); + assert!(contains_dangerous_sql_keyword("ALTER TABLE users ADD COLUMN age INT")); + assert!(contains_dangerous_sql_keyword("UPDATE users SET name = 'x'")); + assert!(contains_dangerous_sql_keyword("MERGE INTO target USING source")); + assert!(contains_dangerous_sql_keyword("REPLACE INTO users VALUES (1)")); + assert!(contains_dangerous_sql_keyword("INSERT INTO users VALUES (1)")); + assert!(contains_dangerous_sql_keyword("CREATE TABLE users (id INT)")); + } + + #[test] + fn contains_dangerous_sql_keyword_ignores_substrings() { + // "updateable" contains "update" as substring but is a different word + assert!(!contains_dangerous_sql_keyword("SELECT * FROM updateable_view")); + // "dropped" contains "drop" as substring + assert!(!contains_dangerous_sql_keyword("SELECT dropped FROM t")); + // "inserted" contains "insert" + assert!(!contains_dangerous_sql_keyword("SELECT inserted FROM t")); + } + + #[test] + fn contains_dangerous_sql_keyword_ignores_in_string_literals() { + assert!(!contains_dangerous_sql_keyword("SELECT 'DROP TABLE users' FROM t")); + assert!(!contains_dangerous_sql_keyword("SELECT 'delete' FROM t")); + assert!(!contains_dangerous_sql_keyword("SELECT \"CREATE TABLE\" FROM t")); + } + + #[test] + fn is_write_sql_detects_simple_writes() { + assert!(is_write_sql("INSERT INTO users VALUES (1)")); + assert!(is_write_sql("UPDATE users SET name = 'x'")); + assert!(is_write_sql("DELETE FROM users")); + assert!(is_write_sql("DROP TABLE users")); + assert!(is_write_sql("CREATE TABLE users (id INT)")); + assert!(is_write_sql("ALTER TABLE users ADD COLUMN age INT")); + assert!(is_write_sql("TRUNCATE TABLE users")); + assert!(is_write_sql( + "MERGE INTO target USING source ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name" + )); + assert!(is_write_sql("REPLACE INTO users VALUES (1)")); + } + + #[test] + fn is_write_sql_allows_reads() { + assert!(!is_write_sql("SELECT * FROM users")); + assert!(!is_write_sql("SELECT id, name FROM users WHERE active = true")); + assert!(!is_write_sql("WITH cte AS (SELECT 1) SELECT * FROM cte")); + assert!(!is_write_sql("SHOW TABLES")); + assert!(!is_write_sql("DESCRIBE users")); + assert!(!is_write_sql("DESC users")); + assert!(!is_write_sql("EXPLAIN SELECT * FROM users")); + assert!(!is_write_sql("PRAGMA table_info(users)")); + assert!(!is_write_sql("FROM users SELECT *")); + } + + #[test] + fn is_write_sql_ignores_leading_whitespace_and_comments() { + assert!(!is_write_sql(" /* comment */ SELECT * FROM users")); + assert!(!is_write_sql("-- comment\nSELECT * FROM users")); + assert!(is_write_sql(" /* comment */ INSERT INTO users VALUES (1)")); + } + + #[test] + fn is_write_sql_cte_with_nested_write() { + // CTE starting with WITH but containing a write operation inside + assert!(is_write_sql("WITH deleted AS (DELETE FROM users RETURNING id) SELECT * FROM deleted")); + assert!(is_write_sql("WITH updated AS (UPDATE users SET name = 'x' RETURNING id) SELECT * FROM updated")); + assert!(is_write_sql("WITH inserted AS (INSERT INTO users VALUES (1) RETURNING id) SELECT * FROM inserted")); + // Pure read CTE should be allowed + assert!(!is_write_sql("WITH cte AS (SELECT * FROM users) SELECT * FROM cte")); + } + + #[test] + fn is_write_sql_case_insensitive() { + assert!(!is_write_sql("select * from users")); + assert!(!is_write_sql("Select * From users")); + assert!(is_write_sql("insert into users values (1)")); + assert!(is_write_sql("Insert Into users Values (1)")); + assert!(is_write_sql("update users set name = 'x'")); + } + + #[test] + fn is_write_sql_edge_cases() { + assert!(!is_write_sql("")); // empty string -> not a write + assert!(!is_write_sql(" ")); // whitespace only -> not a write + assert!(is_write_sql("COMMIT")); // not a recognized read keyword + assert!(is_write_sql("ROLLBACK")); // not a recognized read keyword + assert!(is_write_sql("BEGIN")); // not a recognized read keyword + assert!(is_write_sql("GRANT SELECT ON users TO admin")); + assert!(is_write_sql("REVOKE SELECT ON users FROM admin")); + } + + #[test] + fn is_write_sql_blocks_stored_procedure_calls() { + // CALL and EXEC don't start with a read keyword, so they are treated as writes + assert!(is_write_sql("CALL my_procedure(1, 2)")); + assert!(is_write_sql("call my_procedure()")); + assert!(is_write_sql("EXEC sp_update_stats")); + assert!(is_write_sql("EXECUTE sp_rename 'old', 'new'")); + assert!(is_write_sql("execute my_func()")); + } + + #[test] + fn is_write_sql_allows_safe_read_pragmas() { + // Read-only PRAGMA forms (function-call style with known safe names) are allowed + assert!(!is_write_sql("PRAGMA table_info(users)")); + assert!(!is_write_sql("PRAGMA table_xinfo(users)")); + assert!(!is_write_sql("PRAGMA index_list(users)")); + assert!(!is_write_sql("PRAGMA index_info(idx_name)")); + assert!(!is_write_sql("PRAGMA foreign_key_list(users)")); + assert!(!is_write_sql("PRAGMA database_list")); + assert!(!is_write_sql("PRAGMA compile_options")); + assert!(!is_write_sql("PRAGMA data_version")); + assert!(!is_write_sql("pragma table_info(users)")); + } + + #[test] + fn is_write_sql_blocks_unsafe_pragmas() { + // Assignment forms are always blocked + assert!(is_write_sql("PRAGMA journal_mode = WAL")); + assert!(is_write_sql("PRAGMA synchronous = OFF")); + assert!(is_write_sql("PRAGMA foreign_keys = ON")); + assert!(is_write_sql("PRAGMA cache_size = -2000")); + assert!(is_write_sql("PRAGMA user_version = 123")); + // Unknown PRAGMA names are blocked + assert!(is_write_sql("PRAGMA writable_schema = ON")); + assert!(is_write_sql("PRAGMA locking_mode = EXCLUSIVE")); + assert!(is_write_sql("PRAGMA temp_store = MEMORY")); + assert!(is_write_sql("PRAGMA some_unknown_pragma")); + } + + #[test] + fn is_write_sql_string_literal_hides_keywords() { + // The dangerous keyword is inside a string literal, so it should NOT be detected + assert!(!is_write_sql("SELECT 'DROP TABLE users' AS hint FROM t")); + assert!(!is_write_sql("SELECT * FROM t WHERE name = 'delete'")); + } + + #[test] + fn check_read_only_success_and_error() { + assert_eq!(check_read_only("SELECT * FROM users", "prod-db"), Ok(())); + assert_eq!(check_read_only("WITH cte AS (SELECT 1) SELECT * FROM cte", "prod-db"), Ok(())); + + let err = check_read_only("DELETE FROM users", "prod-db"); + assert!(err.is_err()); + assert_eq!( + err.unwrap_err(), + "Read-only mode: connection 'prod-db' has read-only protection enabled. Write operation (including stored procedure calls) blocked." + ); + + let err2 = check_read_only("UPDATE users SET name = 'x'", "reporting-db"); + assert!(err2.is_err()); + assert!(err2.unwrap_err().contains("reporting-db")); + } + + #[test] + fn strip_sql_comments_basic() { + assert_eq!(strip_sql_comments("SELECT 1"), "SELECT 1"); + assert_eq!(strip_sql_comments("-- comment\nSELECT 1"), " SELECT 1"); + assert_eq!(strip_sql_comments("/* block */ SELECT 1"), " SELECT 1"); + assert_eq!(strip_sql_comments("# comment\nSELECT 1"), " SELECT 1"); + } + + #[test] + fn strip_sql_comments_preserves_strings() { + // strip_sql_comments does NOT handle string delimiters, so it strips + // comments even inside string literals + assert_eq!(strip_sql_comments("SELECT 'hello /* not a comment */'"), "SELECT 'hello '"); + } } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 6e3cce50e..ed36e2c1a 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -658,6 +658,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index e6b4dad58..d480b8319 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -1624,6 +1624,8 @@ pub async fn execute_on_pool_with_max_rows( sql: &str, max_rows: Option, ) -> Result { + // Read-only check: block transfer operations in readonly mode + crate::query::check_read_only_for_connection(state, pool_key, sql).await?; let connections = state.connections.read().await; let pool = connections.get(pool_key).ok_or("Connection not found")?; @@ -3155,6 +3157,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 87fdf91fb..26d1cbdb8 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -47,6 +47,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 99f9ee5ef..6ba398c2b 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -199,6 +199,7 @@ mod tests { jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, + read_only: false, } } diff --git a/crates/dbx-web/src/routes/etcd.rs b/crates/dbx-web/src/routes/etcd.rs index c9ce078f8..3a1987124 100644 --- a/crates/dbx-web/src/routes/etcd.rs +++ b/crates/dbx-web/src/routes/etcd.rs @@ -7,6 +7,21 @@ use serde::Deserialize; use crate::error::AppError; use crate::state::WebState; +/// Check if a connection is read-only and return an error if so. +async fn ensure_writable( + app: &dbx_core::connection::AppState, + connection_id: &str, + action: &str, +) -> Result<(), AppError> { + if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", + name, action + ))); + } + Ok(()) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct EtcdListPrefixRequest { @@ -60,6 +75,7 @@ pub async fn put( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Put").await?; let result = dbx_core::agent_kv::kv_put_core(&state.app, &req.connection_id, &req.key, req.value, req.lease) .await .map_err(AppError)?; @@ -70,6 +86,7 @@ pub async fn delete( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete").await?; let result = dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?; Ok(Json(result)) diff --git a/crates/dbx-web/src/routes/mongo.rs b/crates/dbx-web/src/routes/mongo.rs index 75d7ab9a4..03abb1799 100644 --- a/crates/dbx-web/src/routes/mongo.rs +++ b/crates/dbx-web/src/routes/mongo.rs @@ -7,6 +7,21 @@ use serde::Deserialize; use crate::error::AppError; use crate::state::WebState; +/// Check if a connection is read-only and return an error if so. +async fn ensure_writable( + app: &dbx_core::connection::AppState, + connection_id: &str, + action: &str, +) -> Result<(), AppError> { + if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", + name, action + ))); + } + Ok(()) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct MongoConnectionRequest { @@ -159,6 +174,7 @@ pub async fn insert_document( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Insert").await?; let result = dbx_core::mongo_ops::mongo_insert_document_core( &state.app, &req.connection_id, @@ -175,6 +191,7 @@ pub async fn insert_documents( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Insert").await?; let result = dbx_core::mongo_ops::mongo_insert_documents_core( &state.app, &req.connection_id, @@ -191,6 +208,7 @@ pub async fn update_document( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Update").await?; let result = dbx_core::mongo_ops::mongo_update_document_core( &state.app, &req.connection_id, @@ -208,6 +226,7 @@ pub async fn update_documents( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Update").await?; let result = dbx_core::mongo_ops::mongo_update_documents_core( &state.app, &req.connection_id, @@ -226,6 +245,7 @@ pub async fn delete_document( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete").await?; let result = dbx_core::mongo_ops::mongo_delete_document_core( &state.app, &req.connection_id, @@ -242,6 +262,7 @@ pub async fn delete_documents( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete").await?; let result = dbx_core::mongo_ops::mongo_delete_documents_core( &state.app, &req.connection_id, diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index 1171c59c9..76b4197cf 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -7,6 +7,21 @@ use serde::Deserialize; use crate::error::AppError; use crate::state::WebState; +/// Check if a connection is read-only and return an error if so. +async fn ensure_writable( + app: &dbx_core::connection::AppState, + connection_id: &str, + action: &str, +) -> Result<(), AppError> { + if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", + name, action + ))); + } + Ok(()) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisConnectionRequest { @@ -162,6 +177,7 @@ pub async fn set_string( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "SET").await?; dbx_core::redis_ops::redis_set_string_in_db_core( &state.app, &req.connection_id, @@ -179,6 +195,7 @@ pub async fn delete_key( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete key").await?; dbx_core::redis_ops::redis_delete_key_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw) .await .map_err(AppError)?; @@ -189,6 +206,7 @@ pub async fn hash_set( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "HSET").await?; let value = req.value.as_deref().unwrap_or(""); dbx_core::redis_ops::redis_hash_set_in_db_core( &state.app, @@ -207,6 +225,7 @@ pub async fn hash_del( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "HDEL").await?; dbx_core::redis_ops::redis_hash_del_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.field) .await .map_err(AppError)?; @@ -217,6 +236,7 @@ pub async fn list_push( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "LPUSH").await?; let value = req.value.as_deref().unwrap_or(""); dbx_core::redis_ops::redis_list_push_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, value) .await @@ -228,6 +248,7 @@ pub async fn list_set( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "LSET").await?; let index = req.index.unwrap_or(0); let value = req.value.as_deref().unwrap_or(""); dbx_core::redis_ops::redis_list_set_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index, value) @@ -240,6 +261,7 @@ pub async fn list_remove( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "LREM").await?; let index = req.index.unwrap_or(0); dbx_core::redis_ops::redis_list_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, index) .await @@ -251,6 +273,7 @@ pub async fn set_add( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "SADD").await?; dbx_core::redis_ops::redis_set_add_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member) .await .map_err(AppError)?; @@ -261,6 +284,7 @@ pub async fn set_remove( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "SREM").await?; dbx_core::redis_ops::redis_set_remove_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, &req.member) .await .map_err(AppError)?; @@ -271,6 +295,7 @@ pub async fn delete_keys( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "Delete keys").await?; let result = dbx_core::redis_ops::redis_delete_keys_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raws) .await @@ -282,6 +307,7 @@ pub async fn flush_db( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "FLUSHDB").await?; dbx_core::redis_ops::redis_flush_db_core(&state.app, &req.connection_id, req.db).await.map_err(AppError)?; Ok(Json(())) } @@ -290,6 +316,18 @@ pub async fn execute_command( State(state): State>, Json(req): Json, ) -> Result, AppError> { + // In read-only mode, only allow safe read commands + if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { + let cmd_name = req.command.split_whitespace().next().unwrap_or(""); + if dbx_core::db::redis_driver::classify_command(cmd_name) + != dbx_core::db::redis_driver::RedisCommandSafety::Allowed + { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.", + name, cmd_name + ))); + } + } let result = dbx_core::redis_ops::redis_execute_command_core(&state.app, &req.connection_id, req.db, &req.command) .await .map_err(AppError)?; diff --git a/crates/dbx-web/src/routes/sql_file.rs b/crates/dbx-web/src/routes/sql_file.rs index 8801a5495..d1624645e 100644 --- a/crates/dbx-web/src/routes/sql_file.rs +++ b/crates/dbx-web/src/routes/sql_file.rs @@ -63,6 +63,15 @@ pub async fn execute_sql_file( Json(body): Json, ) -> Result, AppError> { let req = body.request; + + // Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute) + if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. SQL file execution blocked.", + name + ))); + } + let execution_id = req.execution_id.clone(); let file_path = validated_uploaded_sql_path(&state.data_dir, &req.file_path)?; let token = CancellationToken::new(); diff --git a/crates/dbx-web/src/routes/table_import.rs b/crates/dbx-web/src/routes/table_import.rs index 594338feb..ddb95180c 100644 --- a/crates/dbx-web/src/routes/table_import.rs +++ b/crates/dbx-web/src/routes/table_import.rs @@ -56,6 +56,15 @@ pub async fn execute_import( Json(body): Json, ) -> Result, AppError> { let req = body.request; + + // Reject import early if the connection is read-only + if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { + return Err(AppError(format!( + "Read-only mode: connection '{}' has read-only protection enabled. Import blocked.", + name + ))); + } + let import_id = req.import_id.clone(); let (tx, _) = tokio::sync::broadcast::channel::(256); diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index 504f6c1ba..281fae522 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -27,6 +27,15 @@ pub async fn start_transfer( Json(body): Json, ) -> Result, AppError> { let req = body.request; + + // Reject transfer early if the target connection is read-only + if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.target_connection_id).await { + return Err(AppError(format!( + "Read-only mode: target connection '{}' has read-only protection enabled. Transfer blocked.", + name + ))); + } + let transfer_id = req.transfer_id.clone(); // Create a broadcast channel for progress diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts index 45d3b1fe5..98f483e89 100644 --- a/packages/node-core/src/connections.ts +++ b/packages/node-core/src/connections.ts @@ -25,6 +25,7 @@ export interface ConnectionConfig { redis_sentinel_username?: string; redis_sentinel_password?: string; redis_sentinel_tls?: boolean; + read_only?: boolean; } export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index fe7764711..9ea8bbaee 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -629,3 +629,19 @@ pub async fn refresh_connections(state: State<'_, Arc>) -> Result<(), state.refresh_connections().await; Ok(()) } + +/// Check whether a connection has read-only protection enabled. +/// Returns an error if the connection is read-only, preventing write operations. +pub async fn ensure_connection_writable( + state: &Arc, + connection_id: &str, + action: &str, +) -> Result<(), String> { + if let Some(name) = dbx_core::query::connection_readonly_name(state, connection_id).await { + return Err(format!( + "Read-only mode: connection '{}' has read-only protection enabled. {} blocked.", + name, action + )); + } + Ok(()) +} diff --git a/src-tauri/src/commands/etcd_cmd.rs b/src-tauri/src/commands/etcd_cmd.rs index 2f46ada45..d8a22d448 100644 --- a/src-tauri/src/commands/etcd_cmd.rs +++ b/src-tauri/src/commands/etcd_cmd.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use tauri::State; -use crate::commands::connection::AppState; +use crate::commands::connection::{ensure_connection_writable, AppState}; use dbx_core::agent_kv::{KvDeleteResponse, KvGetResponse, KvListPrefixResponse, KvPutResponse, KvValue}; #[tauri::command] @@ -32,6 +32,7 @@ pub async fn etcd_put( value: KvValue, lease: Option, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Put").await?; dbx_core::agent_kv::kv_put_core(&state, &connection_id, &key, value, lease).await } @@ -41,5 +42,6 @@ pub async fn etcd_delete( connection_id: String, key: String, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete").await?; dbx_core::agent_kv::kv_delete_core(&state, &connection_id, &key).await } diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs index 02435de9f..e559a3d85 100644 --- a/src-tauri/src/commands/mcp_bridge.rs +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -6,6 +6,8 @@ use tokio::net::TcpListener; use super::connection::AppState; +use super::connection::ensure_connection_writable; + const BIND_ADDR: &str = "127.0.0.1:0"; #[derive(Deserialize)] @@ -225,7 +227,7 @@ async fn resolve_mongo_pool_key( connection_name: &str, database: Option, stream: &mut tokio::net::TcpStream, -) -> Option<(String, String)> { +) -> Option<(String, String, String)> { let config = match resolve_connection(state, connection_name).await { Ok(c) => c, Err(e) => { @@ -233,6 +235,7 @@ async fn resolve_mongo_pool_key( return None; } }; + let connection_id = config.id.clone(); let database = database.unwrap_or_else(|| config.database.clone().unwrap_or_default()); let pool_key = match state.get_or_create_pool(&config.id, Some(&database)).await { Ok(key) => key, @@ -241,7 +244,7 @@ async fn resolve_mongo_pool_key( return None; } }; - Some((pool_key, database)) + Some((pool_key, database, connection_id)) } async fn handle_open_table(app: &AppHandle, state: &Arc, body: &str, stream: &mut tokio::net::TcpStream) { @@ -365,7 +368,8 @@ async fn handle_mongo_list_collections_data(state: &Arc, body: &str, s return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, _connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; @@ -383,7 +387,8 @@ async fn handle_mongo_find_documents_data(state: &Arc, body: &str, str return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, _connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; @@ -412,7 +417,8 @@ async fn handle_mongo_aggregate_documents_data(state: &Arc, body: &str return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, _connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; @@ -439,10 +445,15 @@ async fn handle_mongo_insert_documents_data(state: &Arc, body: &str, s return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; + if let Err(e) = ensure_connection_writable(state, &connection_id, "Insert").await { + respond_error(stream, "403 Forbidden", &e).await; + return; + } match dbx_core::mongo_ops::mongo_insert_documents_core(state, &pool_key, &database, &req.collection, &req.docs_json) .await { @@ -459,10 +470,15 @@ async fn handle_mongo_update_documents_data(state: &Arc, body: &str, s return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; + if let Err(e) = ensure_connection_writable(state, &connection_id, "Update").await { + respond_error(stream, "403 Forbidden", &e).await; + return; + } match dbx_core::mongo_ops::mongo_update_documents_core( state, &pool_key, @@ -487,10 +503,15 @@ async fn handle_mongo_delete_documents_data(state: &Arc, body: &str, s return; } }; - let Some((pool_key, database)) = resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await + let Some((pool_key, database, connection_id)) = + resolve_mongo_pool_key(state, &req.connection_name, req.database, stream).await else { return; }; + if let Err(e) = ensure_connection_writable(state, &connection_id, "Delete").await { + respond_error(stream, "403 Forbidden", &e).await; + return; + } match dbx_core::mongo_ops::mongo_delete_documents_core( state, &pool_key, @@ -526,6 +547,11 @@ async fn handle_execute_query_data(state: &Arc, body: &str, stream: &m respond_error(stream, "403 Forbidden", &e).await; return; } + // Read-only check: reject if the connection has read-only protection and the SQL is a write + if let Err(e) = dbx_core::query::check_read_only_for_connection(state, &config.id, &req.sql).await { + respond_error(stream, "403 Forbidden", &e).await; + return; + } match dbx_core::query::execute_sql_statement(state, &config.id, &database, &req.sql, req.schema.as_deref(), None) .await { diff --git a/src-tauri/src/commands/mongo_cmd.rs b/src-tauri/src/commands/mongo_cmd.rs index b3d37bc61..d44334438 100644 --- a/src-tauri/src/commands/mongo_cmd.rs +++ b/src-tauri/src/commands/mongo_cmd.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use tauri::State; -use crate::commands::connection::AppState; +use crate::commands::connection::{ensure_connection_writable, AppState}; use dbx_core::db::mongo_driver::MongoDocumentResult; #[tauri::command] @@ -74,6 +74,7 @@ pub async fn mongo_insert_document( collection: String, doc_json: String, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Insert").await?; dbx_core::mongo_ops::mongo_insert_document_core(&state, &connection_id, &database, &collection, &doc_json).await } @@ -85,6 +86,7 @@ pub async fn mongo_insert_documents( collection: String, docs_json: String, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Insert").await?; dbx_core::mongo_ops::mongo_insert_documents_core(&state, &connection_id, &database, &collection, &docs_json).await } @@ -97,6 +99,7 @@ pub async fn mongo_update_document( id: String, doc_json: String, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Update").await?; dbx_core::mongo_ops::mongo_update_document_core(&state, &connection_id, &database, &collection, &id, &doc_json) .await } @@ -111,6 +114,7 @@ pub async fn mongo_update_documents( update_json: String, many: bool, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Update").await?; dbx_core::mongo_ops::mongo_update_documents_core( &state, &connection_id, @@ -131,6 +135,7 @@ pub async fn mongo_delete_document( collection: String, id: String, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete").await?; dbx_core::mongo_ops::mongo_delete_document_core(&state, &connection_id, &database, &collection, &id).await } @@ -143,6 +148,7 @@ pub async fn mongo_delete_documents( filter_json: String, many: bool, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete").await?; dbx_core::mongo_ops::mongo_delete_documents_core(&state, &connection_id, &database, &collection, &filter_json, many) .await } diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index ccf115feb..fc05eb002 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -1,8 +1,10 @@ use std::sync::Arc; use tauri::State; -use crate::commands::connection::AppState; -use dbx_core::db::redis_driver::{RedisCommandResult, RedisDatabaseInfo, RedisScanResult, RedisValue}; +use crate::commands::connection::{ensure_connection_writable, AppState}; +use dbx_core::db::redis_driver::{ + RedisCommandResult, RedisCommandSafety, RedisDatabaseInfo, RedisScanResult, RedisValue, +}; #[tauri::command] pub async fn redis_list_databases( @@ -56,6 +58,7 @@ pub async fn redis_set_string( value: String, ttl: Option, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "SET").await?; dbx_core::redis_ops::redis_set_string_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await } @@ -66,6 +69,7 @@ pub async fn redis_delete_key( db: u32, key_raw: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "Delete key").await?; dbx_core::redis_ops::redis_delete_key_in_db_core(&state, &connection_id, db, &key_raw).await } @@ -78,6 +82,7 @@ pub async fn redis_hash_set( field: String, value: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "HSET").await?; dbx_core::redis_ops::redis_hash_set_in_db_core(&state, &connection_id, db, &key_raw, &field, &value).await } @@ -89,6 +94,7 @@ pub async fn redis_hash_del( key_raw: String, field: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "HDEL").await?; dbx_core::redis_ops::redis_hash_del_in_db_core(&state, &connection_id, db, &key_raw, &field).await } @@ -100,6 +106,7 @@ pub async fn redis_list_push( key_raw: String, value: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "LPUSH").await?; dbx_core::redis_ops::redis_list_push_in_db_core(&state, &connection_id, db, &key_raw, &value).await } @@ -112,6 +119,7 @@ pub async fn redis_list_set( index: i64, value: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "LSET").await?; dbx_core::redis_ops::redis_list_set_in_db_core(&state, &connection_id, db, &key_raw, index, &value).await } @@ -123,6 +131,7 @@ pub async fn redis_list_remove( key_raw: String, index: i64, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "LREM").await?; dbx_core::redis_ops::redis_list_remove_in_db_core(&state, &connection_id, db, &key_raw, index).await } @@ -134,6 +143,7 @@ pub async fn redis_set_add( key_raw: String, member: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "SADD").await?; dbx_core::redis_ops::redis_set_add_in_db_core(&state, &connection_id, db, &key_raw, &member).await } @@ -145,6 +155,7 @@ pub async fn redis_set_remove( key_raw: String, member: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "SREM").await?; dbx_core::redis_ops::redis_set_remove_in_db_core(&state, &connection_id, db, &key_raw, &member).await } @@ -157,6 +168,7 @@ pub async fn redis_zadd( member: String, score: f64, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "ZADD").await?; dbx_core::redis_ops::redis_zadd_in_db_core(&state, &connection_id, db, &key_raw, &member, score).await } @@ -168,6 +180,7 @@ pub async fn redis_zrem( key_raw: String, member: String, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "ZREM").await?; dbx_core::redis_ops::redis_zrem_in_db_core(&state, &connection_id, db, &key_raw, &member).await } @@ -179,6 +192,7 @@ pub async fn redis_set_ttl( key_raw: String, ttl: i64, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "EXPIRE").await?; dbx_core::redis_ops::redis_set_ttl_in_db_core(&state, &connection_id, db, &key_raw, ttl).await } @@ -189,11 +203,13 @@ pub async fn redis_delete_keys( db: u32, key_raws: Vec, ) -> Result { + ensure_connection_writable(&state, &connection_id, "Delete keys").await?; dbx_core::redis_ops::redis_delete_keys_in_db_core(&state, &connection_id, db, &key_raws).await } #[tauri::command] pub async fn redis_flush_db(state: State<'_, Arc>, connection_id: String, db: u32) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "FLUSHDB").await?; dbx_core::redis_ops::redis_flush_db_core(&state, &connection_id, db).await } @@ -204,6 +220,16 @@ pub async fn redis_execute_command( db: u32, command: String, ) -> Result { + // In read-only mode, only allow safe read commands through the raw command interface + if let Some(name) = dbx_core::query::connection_readonly_name(&state, &connection_id).await { + let cmd_name = command.split_whitespace().next().unwrap_or(""); + if dbx_core::db::redis_driver::classify_command(cmd_name) != RedisCommandSafety::Allowed { + return Err(format!( + "Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.", + name, cmd_name + )); + } + } dbx_core::redis_ops::redis_execute_command_core(&state, &connection_id, db, &command).await } diff --git a/src-tauri/src/commands/sql_file.rs b/src-tauri/src/commands/sql_file.rs index 10d4729b0..a12a76933 100644 --- a/src-tauri/src/commands/sql_file.rs +++ b/src-tauri/src/commands/sql_file.rs @@ -7,7 +7,7 @@ use tauri::{AppHandle, Emitter, State}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; -use crate::commands::connection::AppState; +use crate::commands::connection::{ensure_connection_writable, AppState}; use dbx_core::sql_file_import::{execute_sql_file_content, sql_file_error_progress, sql_file_progress}; pub use dbx_core::sql::{decode_sql_file_bytes, SqlFilePreview, SqlFileRequest, SqlFileStatus}; @@ -48,6 +48,8 @@ pub async fn execute_sql_file( state: State<'_, Arc>, request: SqlFileRequest, ) -> Result<(), String> { + // Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute) + ensure_connection_writable(&state, &request.connection_id, "SQL file execution").await?; let token = CancellationToken::new(); { let mut executions = sql_file_executions().write().await; diff --git a/src-tauri/src/commands/table_import.rs b/src-tauri/src/commands/table_import.rs index 5c7a8bde2..eb871a5dc 100644 --- a/src-tauri/src/commands/table_import.rs +++ b/src-tauri/src/commands/table_import.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, OnceLock}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::RwLock; -use crate::commands::connection::AppState; +use crate::commands::connection::{ensure_connection_writable, AppState}; use crate::commands::transfer::get_db_type; // Re-export types for backward compatibility @@ -40,6 +40,8 @@ pub async fn import_table_file( request: TableImportRequest, ) -> Result { clear_cancelled(&request.import_id).await; + // Reject import early if the connection is read-only — importing is inherently a write operation + ensure_connection_writable(&state, &request.connection_id, "Import").await?; let db_type = get_db_type(&state, &request.connection_id).await?; let pool_key = if request.database.is_empty() { request.connection_id.clone() diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 8ffbb1385..bdbe9fba2 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; -use crate::commands::connection::AppState; +use crate::commands::connection::{ensure_connection_writable, AppState}; // Re-export types and functions used by other modules pub use dbx_core::transfer::{get_db_type, TransferProgress, TransferRequest, TransferStatus}; @@ -19,6 +19,9 @@ pub async fn start_transfer( let state = state.inner().clone(); let transfer_id = request.transfer_id.clone(); + // Reject transfer early if the target connection is read-only — writing to it is inherently required + ensure_connection_writable(&state, &request.target_connection_id, "Transfer").await?; + // Validate connections exist let source_db_type = get_db_type(&state, &request.source_connection_id).await?; let target_db_type = get_db_type(&state, &request.target_connection_id).await?;