feat(readonly): add read-only mode for all database connections
## Background Add a read-only toggle for database connections that blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) when enabled, allowing only read queries (SELECT, SHOW, EXPLAIN, etc.). Closes #889. ## Changes ### Data Model - Added `read_only: bool` field to ConnectionConfig (Rust struct, TS interfaces, and ConnectionConfigData deserialization layer) - Used `#[serde(default, skip_serializing_if = "is_false")]` for backward/forward compatibility with existing configs ### Frontend UI - Added "Read Only" checkbox in connection dialog - 7-language i18n support (en/es/it/pt-BR/zh-CN/zh-TW) ### SQL Classification (query_execution_sql.rs) - Added `is_write_sql()` with two-layer defense: - Layer 1: First-keyword check (must start with known read keyword) - Layer 2: Embedded dangerous keyword detection (catches CTE-wrapped writes like `WITH ... AS (DELETE FROM ...)`) - `FROM` keyword supported as DuckDB SELECT-less syntax indicator - Added `check_read_only()` returning descriptive error with connection name - Added 16 unit tests covering: pure reads/writes, CTE, case insensitivity, string literal masking, comment stripping, stored procedure calls, edge cases ### SQL Execution Guards (query.rs / transfer.rs) - Added `check_read_only_for_connection()` and `_multi()` helper functions with lazy name clone (only allocates when read_only is true) - 6 interception points: `do_execute`, MySQL batch, SQL Server batch, DuckDB batch, transaction execution, transfer execution ### Non-SQL Write Guards — Tauri Commands - Added `ensure_connection_writable()` helper (connection.rs) - Mongo: 6 write entry points (insert/update/delete, single + batch) - Redis: 15 write entry points (SET, DEL, HSET, HDEL, LPUSH, LSET, LREM, SADD, SREM, ZADD, ZREM, EXPIRE, FLUSHDB, delete_keys, execute_command) - execute_command uses RedisCommandSafety classification to allow safe read commands through raw command interface - etcd: 2 write entry points (put, delete) - sql_file: SQL file execution guarded - MCP Bridge: 4 write entry points (Insert/Update/Delete/SQL query) ### Non-SQL Write Guards — Web API - Local `ensure_writable()` helper in each route module - Redis: 12 write endpoints including classified execute_command - Mongo: 6 write endpoints - etcd: 2 write endpoints - sql_file, table_import, transfer: early rejection ### Test Updates - Updated ConnectionConfig construction in 7 test files with `read_only: false` initialization ## Defense-in-Depth - Layer 1: Command/Route-level early rejection (saves resources) - Layer 2: Core SQL classifier (`is_write_sql` — first keyword + embedded scan) - Layer 3: Core execution-time interception (do_execute/transfer/transaction)
This commit is contained in:
parent
7798f2231e
commit
a440415e86
|
|
@ -60,3 +60,4 @@ releases-*.json
|
|||
test.pdb
|
||||
portable/
|
||||
DBX_*_x64-portable.zip
|
||||
.agents/skills
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t("connection.readOnly") }}</Label>
|
||||
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="form.read_only" class="mr-0" />
|
||||
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ export default {
|
|||
connectTimeout: "连接超时(秒)",
|
||||
queryTimeout: "查询超时(秒)",
|
||||
idleTimeout: "空闲超时(秒)",
|
||||
readOnly: "只读模式",
|
||||
readOnlyHint: "阻止所有写操作(INSERT、UPDATE、DELETE 等)",
|
||||
proxy: "代理",
|
||||
proxyEnable: "通过代理连接数据库",
|
||||
proxyType: "代理类型",
|
||||
|
|
|
|||
|
|
@ -259,6 +259,8 @@ export default {
|
|||
connectTimeout: "連線逾時(秒)",
|
||||
queryTimeout: "查詢逾時(秒)",
|
||||
idleTimeout: "閒置逾時(秒)",
|
||||
readOnly: "唯讀模式",
|
||||
readOnlyHint: "阻止所有寫入操作(INSERT、UPDATE、DELETE 等)",
|
||||
proxy: "代理伺服器",
|
||||
proxyEnable: "藉由代理伺服器連線資料庫",
|
||||
proxyType: "代理伺服器類型",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ mod tests {
|
|||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -1210,6 +1210,7 @@ mod tests {
|
|||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -511,6 +511,7 @@ mod tests {
|
|||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ pub struct ConnectionConfig {
|
|||
pub jdbc_driver_paths: Vec<String>,
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub one_time: bool,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
impl From<ConnectionConfigData> for ConnectionConfig {
|
||||
|
|
@ -383,6 +387,7 @@ impl From<ConnectionConfigData> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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<String> {
|
||||
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<db::QueryResult, String> {
|
||||
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::<Vec<_>>())
|
||||
.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::<Vec<_>>())
|
||||
.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<Vec<db::QueryResult>, 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(
|
||||
|
|
|
|||
|
|
@ -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 '");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -658,6 +658,7 @@ mod tests {
|
|||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1624,6 +1624,8 @@ pub async fn execute_on_pool_with_max_rows(
|
|||
sql: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ mod tests {
|
|||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<EtcdPutRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvPutResponse>, 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<Arc<WebState>>,
|
||||
Json(req): Json<EtcdKeyRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvDeleteResponse>, 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))
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertRequest>,
|
||||
) -> Result<Json<String>, 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<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
|
||||
Json(req): Json<MongoUpdateRequest>,
|
||||
) -> Result<Json<u64>, 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<Arc<WebState>>,
|
||||
Json(req): Json<MongoUpdateDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
|
||||
Json(req): Json<MongoDeleteRequest>,
|
||||
) -> Result<Json<u64>, 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<Arc<WebState>>,
|
||||
Json(req): Json<MongoDeleteDocumentsRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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,
|
||||
|
|
|
|||
|
|
@ -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<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetStringRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeyRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeysRequest>,
|
||||
) -> Result<Json<u64>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisDbRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<WebState>>,
|
||||
Json(req): Json<RedisCommandRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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)?;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@ pub async fn execute_sql_file(
|
|||
Json(body): Json<SqlFileExecuteWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, 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();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ pub async fn execute_import(
|
|||
Json(body): Json<ExecuteImportWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, 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::<String>(256);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ pub async fn start_transfer(
|
|||
Json(body): Json<StartTransferRequest>,
|
||||
) -> Result<Json<serde_json::Value>, 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -629,3 +629,19 @@ pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> 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<AppState>,
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64>,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
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<KvDeleteResponse, String> {
|
||||
ensure_connection_writable(&state, &connection_id, "Delete").await?;
|
||||
dbx_core::agent_kv::kv_delete_core(&state, &connection_id, &key).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<AppState>, body: &str, stream: &mut tokio::net::TcpStream) {
|
||||
|
|
@ -365,7 +368,8 @@ async fn handle_mongo_list_collections_data(state: &Arc<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64>,
|
||||
) -> 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<String>,
|
||||
) -> Result<u64, String> {
|
||||
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<AppState>>, 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<RedisCommandResult, String> {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState>>,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<TableImportSummary, String> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue