mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 08:20:33 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user