diff --git a/src-tauri/src/drivers/mysql/explain.rs b/src-tauri/src/drivers/mysql/explain.rs index fa555369..6f2ffa95 100644 --- a/src-tauri/src/drivers/mysql/explain.rs +++ b/src-tauri/src/drivers/mysql/explain.rs @@ -61,13 +61,22 @@ pub async fn explain_query( get_mysql_pool(params).await? }; + // Behind a bastion that rejects prepared statements, EXPLAIN variants must + // run over the text protocol (COM_QUERY) — see `super::force_text_protocol`. + let text = super::force_text_protocol(params); + // Detect server version to skip unsupported EXPLAIN variants let caps = { let mut vc = pool.acquire().await.map_err(|e| e.to_string())?; - let ver_row = sqlx::query("SELECT VERSION()") - .fetch_one(&mut *vc) - .await - .ok(); + let ver_row = if text { + use sqlx::Executor; + (&mut *vc) + .fetch_one(sqlx::raw_sql("SELECT VERSION()")) + .await + } else { + sqlx::query("SELECT VERSION()").fetch_one(&mut *vc).await + } + .ok(); let ver_str: String = ver_row.and_then(|r| r.try_get(0).ok()).unwrap_or_default(); log::debug!("MySQL/MariaDB version: {}", ver_str); parse_mysql_version(&ver_str) @@ -77,7 +86,13 @@ pub async fn explain_query( if analyze && caps.supports_explain_analyze { let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let analyze_sql = format!("EXPLAIN ANALYZE {}", query); - if let Ok(rows) = sqlx::query(&analyze_sql).fetch_all(&mut *conn).await { + let analyze_res = if text { + use sqlx::Executor; + (&mut *conn).fetch_all(sqlx::raw_sql(&analyze_sql)).await + } else { + sqlx::query(&analyze_sql).fetch_all(&mut *conn).await + }; + if let Ok(rows) = analyze_res { let mut lines = Vec::new(); for row in &rows { if let Ok(line) = row.try_get::(0) { @@ -108,7 +123,13 @@ pub async fn explain_query( if analyze && caps.supports_analyze_format { let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let maria_sql = format!("ANALYZE FORMAT=JSON {}", query); - if let Ok(row) = sqlx::query(&maria_sql).fetch_one(&mut *conn).await { + let maria_res = if text { + use sqlx::Executor; + (&mut *conn).fetch_one(sqlx::raw_sql(&maria_sql)).await + } else { + sqlx::query(&maria_sql).fetch_one(&mut *conn).await + }; + if let Ok(row) = maria_res { if let Ok(raw_json) = row.try_get::(0) { if let Ok(json_val) = serde_json::from_str::(&raw_json) { if let Some(query_block) = json_val.get("query_block") { @@ -142,10 +163,13 @@ pub async fn explain_query( let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let json_sql = format!("EXPLAIN FORMAT=JSON {}", query); let json_result: Result = async { - let row = sqlx::query(&json_sql) - .fetch_one(&mut *conn) - .await - .map_err(|e| e.to_string())?; + let row = if text { + use sqlx::Executor; + (&mut *conn).fetch_one(sqlx::raw_sql(&json_sql)).await + } else { + sqlx::query(&json_sql).fetch_one(&mut *conn).await + } + .map_err(|e| e.to_string())?; row.try_get::(0).map_err(|e| e.to_string()) } .await; @@ -174,10 +198,13 @@ pub async fn explain_query( // Tabular fallback — works on all MySQL/MariaDB versions let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let explain_sql = format!("EXPLAIN {}", query); - let rows = sqlx::query(&explain_sql) - .fetch_all(&mut *conn) - .await - .map_err(|e| e.to_string())?; + let rows = if text { + use sqlx::Executor; + (&mut *conn).fetch_all(sqlx::raw_sql(&explain_sql)).await + } else { + sqlx::query(&explain_sql).fetch_all(&mut *conn).await + } + .map_err(|e| e.to_string())?; let (root, raw) = parse_mysql_tabular_explain(&rows); Ok(ExplainPlan { diff --git a/src-tauri/src/drivers/mysql/export.rs b/src-tauri/src/drivers/mysql/export.rs index c3b1b4ba..3000f899 100644 --- a/src-tauri/src/drivers/mysql/export.rs +++ b/src-tauri/src/drivers/mysql/export.rs @@ -22,7 +22,13 @@ where F: FnMut(&[String], &[Value]) -> Result<(), String> + Send, { let pool = get_mysql_pool(params).await?; - let mut rows = sqlx::query(query).fetch(&pool); + // Behind a bastion that rejects prepared statements, stream over the text + // protocol (COM_QUERY) instead — see `super::force_text_protocol`. + let mut rows = if super::force_text_protocol(params) { + sqlx::raw_sql(query).fetch(&pool) + } else { + sqlx::query(query).fetch(&pool) + }; let mut headers: Option> = None; while let Some(row_res) = rows.next().await { diff --git a/src-tauri/src/drivers/mysql/helpers.rs b/src-tauri/src/drivers/mysql/helpers.rs index a3a101ae..0a669c73 100644 --- a/src-tauri/src/drivers/mysql/helpers.rs +++ b/src-tauri/src/drivers/mysql/helpers.rs @@ -5,6 +5,98 @@ pub(super) fn escape_identifier(name: &str) -> String { name.replace('`', "``") } +/// Renders a `&str` as a quoted MySQL string literal for the text protocol. +/// +/// Used when a query has to bypass the prepared-statement protocol (e.g. +/// behind a Warpgate-style bastion that rejects `COM_STMT_PREPARE`): the +/// value can no longer travel as a bind parameter, so it is inlined as an +/// escaped literal instead. +/// +/// The escaping depends on the server's `sql_mode`: when `NO_BACKSLASH_ESCAPES` +/// is set (ANSI mode, some bastion targets) the backslash is an ordinary +/// character, so a value like `o\'brien` must close the quote by doubling it +/// (`''`) rather than `\'` — otherwise the literal is mis-parsed and user cell +/// values become an injection vector. Quote doubling is also valid in the +/// default mode, but backslash escaping is not portable, so callers must pass +/// the actual server setting via `no_backslash_escapes`. +pub(super) fn mysql_string_literal(s: &str, no_backslash_escapes: bool) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + if no_backslash_escapes { + // Backslash is literal here; the single quote is the only metacharacter + // inside the literal and is escaped by doubling it. Everything else + // (including control bytes and backslashes) is emitted verbatim. + for ch in s.chars() { + if ch == '\'' { + out.push_str("''"); + } else { + out.push(ch); + } + } + } else { + // Default mode: mirror `mysql_real_escape_string` (backslash escapes on). + for ch in s.chars() { + match ch { + '\0' => out.push_str("\\0"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '"' => out.push_str("\\\""), + '\u{1a}' => out.push_str("\\Z"), + c => out.push(c), + } + } + } + out.push('\''); + out +} + +/// Renders raw bytes as a MySQL hexadecimal literal (`x'..'`) for the text +/// protocol — the inlined equivalent of binding a `Vec` blob parameter. +pub(super) fn mysql_bytes_literal(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2 + 3); + out.push_str("x'"); + for b in bytes { + let _ = write!(out, "{:02x}", b); + } + out.push('\''); + out +} + +/// Substitutes each `?` placeholder in `sql` with the next quoted string +/// literal from `binds`, in order. Used to turn a parameterised +/// introspection query into a text-protocol statement. Placeholders past +/// the end of `binds` (and `?` chars when `binds` is empty) are left as-is. +/// `no_backslash_escapes` is forwarded to [`mysql_string_literal`] so the +/// literals match the server's `sql_mode`. +/// +/// # Safety +/// +/// This treats every `?` as a bind placeholder, so it is only sound for the +/// driver's own hand-written introspection queries (whose `?` chars are +/// exclusively placeholders). It must never be used to render arbitrary user +/// SQL, where a `?` could appear inside a string literal. +pub(super) fn inline_str_placeholders( + sql: &str, + binds: &[&str], + no_backslash_escapes: bool, +) -> String { + let mut out = String::with_capacity(sql.len()); + let mut iter = binds.iter(); + for ch in sql.chars() { + if ch == '?' { + if let Some(b) = iter.next() { + out.push_str(&mysql_string_literal(b, no_backslash_escapes)); + continue; + } + } + out.push(ch); + } + out +} + /// Read a string from a MySQL row by index. /// MySQL 8 information_schema returns VARBINARY/BLOB instead of VARCHAR, /// so try_get:: fails silently. This falls back to reading raw bytes. diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 1bf9d22c..07242ad0 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -17,20 +17,224 @@ use crate::pool_manager::get_mysql_pool; pub use explain::explain_query; use extract::extract_value; use helpers::{ - escape_identifier, is_raw_sql_function, is_wkt_geometry, mysql_row_str, mysql_row_str_opt, + escape_identifier, inline_str_placeholders, is_raw_sql_function, is_wkt_geometry, + mysql_bytes_literal, mysql_row_str, mysql_row_str_opt, mysql_string_literal, }; use sqlx::{Column, Row}; +/// Whether this connection must avoid the prepared-statement protocol. +/// +/// Bastions like Warpgate proxy MySQL but do **not** implement +/// `COM_STMT_PREPARE`; any `sqlx::query()` (which always prepares) fails with +/// server error 1047 ("Not implemented"). The cleartext auth plugin is only +/// ever enabled to authenticate through such a bastion, so we treat it as the +/// signal to route every statement through the text protocol (`COM_QUERY` via +/// `sqlx::raw_sql`) instead. See [`crate::pool_manager::build_mysql_options`]. +pub(super) fn force_text_protocol(params: &ConnectionParams) -> bool { + params.enable_cleartext_plugin.unwrap_or(false) +} + +/// How a single operation must render statements on the wire. +/// +/// Resolved once per public driver call via [`resolve_text_proto`]. In the +/// normal case values are bound through the prepared-statement protocol; behind +/// a bastion (`enabled`) they are inlined as escaped literals, whose escaping +/// must match the server's `sql_mode` (`no_backslash_escapes`). +#[derive(Clone, Copy)] +pub(super) struct TextProto { + /// Inline values as literals instead of binding them. + enabled: bool, + /// Server runs with `NO_BACKSLASH_ESCAPES`, so string literals double the + /// quote (`''`) instead of backslash-escaping it. + no_backslash_escapes: bool, +} + +impl TextProto { + /// Prepared-statement mode: bind everything, never inline. + const PREPARED: TextProto = TextProto { + enabled: false, + no_backslash_escapes: false, + }; + + /// Protocol selection only, for paths that run SQL verbatim and never + /// inline literals — so the `sql_mode`-dependent escaping is irrelevant and + /// no `@@sql_mode` roundtrip is needed. + const fn protocol_only(enabled: bool) -> TextProto { + TextProto { + enabled, + no_backslash_escapes: false, + } + } +} + +/// Resolves how to render statements for one operation. When the text protocol +/// is forced (bastion path) the server's `sql_mode` is queried once so inlined +/// literals are escaped correctly; otherwise no extra roundtrip is made. +async fn resolve_text_proto( + pool: &sqlx::MySqlPool, + params: &ConnectionParams, +) -> Result { + if force_text_protocol(params) { + Ok(TextProto { + enabled: true, + no_backslash_escapes: server_no_backslash_escapes(pool).await?, + }) + } else { + Ok(TextProto::PREPARED) + } +} + +/// Reads `@@sql_mode` and reports whether `NO_BACKSLASH_ESCAPES` is in effect. +/// Issued over the text protocol because the bastion path can't prepare. +async fn server_no_backslash_escapes(pool: &sqlx::MySqlPool) -> Result { + use sqlx::Executor; + let row = pool + .fetch_one(sqlx::raw_sql("SELECT @@sql_mode")) + .await + .map_err(|e| e.to_string())?; + let mode = mysql_row_str(&row, 0); + Ok(mode + .split(',') + .any(|m| m.trim().eq_ignore_ascii_case("NO_BACKSLASH_ESCAPES"))) +} + +/// Runs a `SELECT` and returns all rows, choosing the wire protocol from +/// `text`. In text mode the `?` placeholders are inlined as escaped string +/// literals (see [`inline_str_placeholders`]); otherwise they are bound +/// through the normal prepared-statement path. `binds` are always string +/// parameters — the only kind the introspection queries use. +async fn fetch_all_rows( + pool: &sqlx::MySqlPool, + text: TextProto, + sql: &str, + binds: &[&str], +) -> Result, String> { + use sqlx::Executor; + if text.enabled { + let rendered = inline_str_placeholders(sql, binds, text.no_backslash_escapes); + pool.fetch_all(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_all(q).await.map_err(|e| e.to_string()) + } +} + +/// `fetch_one` variant of [`fetch_all_rows`]. +async fn fetch_one_row( + pool: &sqlx::MySqlPool, + text: TextProto, + sql: &str, + binds: &[&str], +) -> Result { + use sqlx::Executor; + if text.enabled { + let rendered = inline_str_placeholders(sql, binds, text.no_backslash_escapes); + pool.fetch_one(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_one(q).await.map_err(|e| e.to_string()) + } +} + +/// `fetch_optional` variant of [`fetch_all_rows`]. +async fn fetch_optional_row( + pool: &sqlx::MySqlPool, + text: TextProto, + sql: &str, + binds: &[&str], +) -> Result, String> { + use sqlx::Executor; + if text.enabled { + let rendered = inline_str_placeholders(sql, binds, text.no_backslash_escapes); + pool.fetch_optional(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_optional(q).await.map_err(|e| e.to_string()) + } +} + +/// Executes a statement that returns no result set (DDL), choosing the wire +/// protocol from `text`. Used for the bind-free `CREATE/ALTER/DROP` helpers. +async fn exec_stmt( + pool: &sqlx::MySqlPool, + text: TextProto, + sql: &str, +) -> Result { + use sqlx::Executor; + if text.enabled { + pool.execute(sqlx::raw_sql(sql)) + .await + .map_err(|e| e.to_string()) + } else { + pool.execute(sqlx::query(sql)) + .await + .map_err(|e| e.to_string()) + } +} + +/// Appends a primary-key comparison value to a `QueryBuilder`, choosing between +/// the prepared-statement (`push_bind`) and text (`push` an inlined literal) +/// protocols based on `text`. Centralises the value rendering shared by +/// [`mysql_fetch_one_with_pk`], [`mysql_execute_with_pk`] and `update_record`'s +/// `WHERE` clause so the bastion (text) path stays consistent with the bound path. +fn push_pk_value( + qb: &mut sqlx::QueryBuilder<'_, sqlx::MySql>, + val: &serde_json::Value, + text: TextProto, +) -> Result<(), String> { + match val { + serde_json::Value::Number(n) => { + if text.enabled { + qb.push(n.to_string()); + } else if n.is_i64() { + qb.push_bind(n.as_i64()); + } else if n.is_f64() { + qb.push_bind(n.as_f64()); + } else { + qb.push_bind(n.to_string()); + } + } + serde_json::Value::String(s) => { + if let Some(n) = parse_unsafe_bigint_string(s) { + if text.enabled { + qb.push(n.to_string()); + } else { + qb.push_bind(n); + } + } else if text.enabled { + qb.push(mysql_string_literal(s, text.no_backslash_escapes)); + } else { + qb.push_bind(s.clone()); + } + } + _ => return Err("Unsupported PK type".into()), + } + Ok(()) +} + pub async fn get_schemas(_params: &ConnectionParams) -> Result, String> { Ok(vec![]) } pub async fn get_databases(params: &ConnectionParams) -> Result, String> { let pool = get_mysql_pool(params).await?; - let rows = sqlx::query("SHOW DATABASES") - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let text = resolve_text_proto(&pool, params).await?; + let rows = fetch_all_rows(&pool, text, "SHOW DATABASES", &[]).await?; Ok(rows.iter().map(|r| mysql_row_str(r, 0)).collect()) } @@ -41,13 +245,14 @@ pub async fn get_tables( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching tables for database: {}", db_name); let pool = get_mysql_pool(params).await?; - let rows = sqlx::query( + let text = resolve_text_proto(&pool, params).await?; + let rows = fetch_all_rows( + &pool, + text, "SELECT table_name as name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name ASC", + &[db_name], ) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + .await?; let tables: Vec = rows .iter() .map(|r| TableInfo { @@ -65,6 +270,7 @@ pub async fn get_columns( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -73,12 +279,7 @@ pub async fn get_columns( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -122,6 +323,7 @@ pub async fn get_foreign_keys( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT @@ -141,12 +343,7 @@ pub async fn get_foreign_keys( ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -169,6 +366,7 @@ pub async fn get_all_columns_batch( use std::collections::HashMap; let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT table_name, column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -177,11 +375,7 @@ pub async fn get_all_columns_batch( ORDER BY table_name, ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let mut result: HashMap> = HashMap::new(); @@ -233,6 +427,7 @@ pub async fn get_all_foreign_keys_batch( use std::collections::HashMap; let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT @@ -252,11 +447,7 @@ pub async fn get_all_foreign_keys_batch( ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let mut result: HashMap> = HashMap::new(); @@ -285,6 +476,7 @@ pub async fn get_indexes( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT @@ -298,12 +490,7 @@ pub async fn get_indexes( ORDER BY INDEX_NAME, SEQ_IN_INDEX "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -385,6 +572,7 @@ async fn mysql_fetch_one_with_pk( pk_map: &HashMap, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let pairs = build_mysql_pk_where(pk_map)?; let mut first = true; let mut qb3 = sqlx::QueryBuilder::::new(format!("{} WHERE ", select_from)); @@ -393,28 +581,17 @@ async fn mysql_fetch_one_with_pk( qb3.push(" AND "); } qb3.push(format!("`{}` = ", escape_identifier(col))); - match val { - serde_json::Value::Number(n) => { - if n.is_i64() { - qb3.push_bind(n.as_i64()); - } else if n.is_f64() { - qb3.push_bind(n.as_f64()); - } else { - qb3.push_bind(n.to_string()); - } - } - serde_json::Value::String(s) => { - if let Some(n) = parse_unsafe_bigint_string(s) { - qb3.push_bind(n); - } else { - qb3.push_bind(s.clone()); - } - } - _ => return Err("Unsupported PK type".into()), - } + push_pk_value(&mut qb3, val, text)?; first = false; } - qb3.build().fetch_one(&pool).await.map_err(|e| e.to_string()) + if text.enabled { + use sqlx::Executor; + pool.fetch_one(sqlx::raw_sql(&qb3.into_sql())) + .await + .map_err(|e| e.to_string()) + } else { + qb3.build().fetch_one(&pool).await.map_err(|e| e.to_string()) + } } /// Execute a DELETE/UPDATE query appending a WHERE clause from pk_map. @@ -425,6 +602,7 @@ async fn mysql_execute_with_pk( pk_map: &HashMap, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let pairs = build_mysql_pk_where(pk_map)?; let mut qb = sqlx::QueryBuilder::::new(format!("{} WHERE ", prefix)); let mut first = true; @@ -433,28 +611,14 @@ async fn mysql_execute_with_pk( qb.push(" AND "); } qb.push(format!("`{}` = ", escape_identifier(col))); - match val { - serde_json::Value::Number(n) => { - if n.is_i64() { - qb.push_bind(n.as_i64()); - } else if n.is_f64() { - qb.push_bind(n.as_f64()); - } else { - qb.push_bind(n.to_string()); - } - } - serde_json::Value::String(s) => { - if let Some(n) = parse_unsafe_bigint_string(s) { - qb.push_bind(n); - } else { - qb.push_bind(s.clone()); - } - } - _ => return Err("Unsupported PK type".into()), - } + push_pk_value(&mut qb, val, text)?; first = false; } - let result = qb.build().execute(&pool).await.map_err(|e| e.to_string())?; + let result = if text.enabled { + exec_stmt(&pool, text, &qb.into_sql()).await? + } else { + qb.build().execute(&pool).await.map_err(|e| e.to_string())? + }; Ok(result.rows_affected()) } @@ -480,6 +644,9 @@ pub async fn update_record( max_blob_size: u64, ) -> Result { let pool = get_mysql_pool(params).await?; + // Behind a prepared-statement-less bastion every value is inlined as an + // escaped literal instead of bound (see `force_text_protocol`). + let text = resolve_text_proto(&pool, params).await?; let pk_pairs = build_mysql_pk_where(pk_map)?; let mut qb = sqlx::QueryBuilder::new(format!( @@ -491,7 +658,13 @@ pub async fn update_record( match new_val { serde_json::Value::Number(n) => { if n.is_i64() { - qb.push_bind(n.as_i64()); + if text.enabled { + qb.push(n.to_string()); + } else { + qb.push_bind(n.as_i64()); + } + } else if text.enabled { + qb.push(n.to_string()); } else { qb.push_bind(n.as_f64()); } @@ -502,21 +675,44 @@ pub async fn update_record( } else if let Some(bytes) = crate::drivers::common::decode_blob_wire_format(&s, max_blob_size) { - qb.push_bind(bytes); + // Blob wire format: decode to raw bytes so the DB stores binary data, + // not the internal wire format string. + if text.enabled { + qb.push(mysql_bytes_literal(&bytes)); + } else { + qb.push_bind(bytes); + } } else if is_raw_sql_function(&s) { qb.push(s); } else if is_wkt_geometry(&s) { qb.push("ST_GeomFromText("); - qb.push_bind(s); + if text.enabled { + qb.push(mysql_string_literal(&s, text.no_backslash_escapes)); + } else { + qb.push_bind(s); + } qb.push(")"); } else if let Some(n) = parse_unsafe_bigint_string(&s) { - qb.push_bind(n); + // Bigints outside JS safe range come back from the UI as strings + // (see drivers::common::i64_to_json). Bind them as native i64 so + // BIGINT columns receive the exact value. + if text.enabled { + qb.push(n.to_string()); + } else { + qb.push_bind(n); + } + } else if text.enabled { + qb.push(mysql_string_literal(&s, text.no_backslash_escapes)); } else { qb.push_bind(s); } } serde_json::Value::Bool(b) => { - qb.push_bind(b); + if text.enabled { + qb.push(if b { "1" } else { "0" }); + } else { + qb.push_bind(b); + } } serde_json::Value::Null => { qb.push("NULL"); @@ -524,7 +720,11 @@ pub async fn update_record( serde_json::Value::Object(_) | serde_json::Value::Array(_) => { let json_str = serde_json::to_string(&new_val).map_err(|e| e.to_string())?; qb.push("CAST("); - qb.push_bind(json_str); + if text.enabled { + qb.push(mysql_string_literal(&json_str, text.no_backslash_escapes)); + } else { + qb.push_bind(json_str); + } qb.push(" AS JSON)"); } } @@ -536,29 +736,15 @@ pub async fn update_record( qb.push(" AND "); } qb.push(format!("`{}` = ", escape_identifier(col))); - match val { - serde_json::Value::Number(n) => { - if n.is_i64() { - qb.push_bind(n.as_i64()); - } else if n.is_f64() { - qb.push_bind(n.as_f64()); - } else { - qb.push_bind(n.to_string()); - } - } - serde_json::Value::String(s) => { - if let Some(n) = parse_unsafe_bigint_string(s) { - qb.push_bind(n); - } else { - qb.push_bind(s.clone()); - } - } - _ => return Err("Unsupported PK type".into()), - } + push_pk_value(&mut qb, val, text)?; first = false; } - let result = qb.build().execute(&pool).await.map_err(|e| e.to_string())?; + let result = if text.enabled { + exec_stmt(&pool, text, &qb.into_sql()).await? + } else { + qb.build().execute(&pool).await.map_err(|e| e.to_string())? + }; Ok(result.rows_affected()) } @@ -569,6 +755,9 @@ pub async fn insert_record( max_blob_size: u64, ) -> Result { let pool = get_mysql_pool(params).await?; + // Behind a prepared-statement-less bastion every value is inlined as an + // escaped literal instead of bound (see `force_text_protocol`). + let text = resolve_text_proto(&pool, params).await?; let mut cols = Vec::new(); let mut vals = Vec::new(); @@ -593,7 +782,13 @@ pub async fn insert_record( match val { serde_json::Value::Number(n) => { if n.is_i64() { - separated.push_bind(n.as_i64()); + if text.enabled { + separated.push(n.to_string()); + } else { + separated.push_bind(n.as_i64()); + } + } else if text.enabled { + separated.push(n.to_string()); } else { separated.push_bind(n.as_f64()); } @@ -604,7 +799,11 @@ pub async fn insert_record( { // Blob wire format: decode to raw bytes so the DB stores binary data, // not the internal wire format string. - separated.push_bind(bytes); + if text.enabled { + separated.push(mysql_bytes_literal(&bytes)); + } else { + separated.push_bind(bytes); + } } else if is_raw_sql_function(&s) { // If it's a raw SQL function (e.g., ST_GeomFromText('POINT(1 2)', 4326)) // insert it directly without parameter binding @@ -612,16 +811,33 @@ pub async fn insert_record( } else if is_wkt_geometry(&s) { // If it's WKT geometry format, wrap with ST_GeomFromText separated.push_unseparated("ST_GeomFromText("); - separated.push_bind_unseparated(s); + if text.enabled { + separated.push_unseparated(mysql_string_literal( + &s, + text.no_backslash_escapes, + )); + } else { + separated.push_bind_unseparated(s); + } separated.push_unseparated(")"); } else if let Some(n) = parse_unsafe_bigint_string(&s) { - separated.push_bind(n); + if text.enabled { + separated.push(n.to_string()); + } else { + separated.push_bind(n); + } + } else if text.enabled { + separated.push(mysql_string_literal(&s, text.no_backslash_escapes)); } else { separated.push_bind(s); } } serde_json::Value::Bool(b) => { - separated.push_bind(b); + if text.enabled { + separated.push(if b { "1" } else { "0" }); + } else { + separated.push_bind(b); + } } serde_json::Value::Null => { separated.push("NULL"); @@ -629,7 +845,14 @@ pub async fn insert_record( serde_json::Value::Object(_) | serde_json::Value::Array(_) => { let json_str = serde_json::to_string(&val).map_err(|e| e.to_string())?; separated.push_unseparated("CAST("); - separated.push_bind_unseparated(json_str); + if text.enabled { + separated.push_unseparated(mysql_string_literal( + &json_str, + text.no_backslash_escapes, + )); + } else { + separated.push_bind_unseparated(json_str); + } separated.push_unseparated(" AS JSON)"); } } @@ -638,18 +861,19 @@ pub async fn insert_record( qb }; - let query = qb.build(); - let result = query.execute(&pool).await.map_err(|e| e.to_string())?; + let result = if text.enabled { + exec_stmt(&pool, text, &qb.into_sql()).await? + } else { + qb.build().execute(&pool).await.map_err(|e| e.to_string())? + }; Ok(result.rows_affected()) } pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str) -> Result { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = format!("SHOW CREATE TABLE `{}`", table_name); - let row = sqlx::query(&query) - .fetch_one(&pool) - .await - .map_err(|e| e.to_string())?; + let row = fetch_one_row(&pool, text, &query, &[]).await?; let create_sql = mysql_row_str(&row, 1); Ok(format!("{};", create_sql)) @@ -662,13 +886,14 @@ pub async fn get_views( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching views for database: {}", db_name); let pool = get_mysql_pool(params).await?; - let rows = sqlx::query( - "SELECT table_name as name FROM information_schema.views WHERE table_schema = ? ORDER BY table_name ASC", - ) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let text = resolve_text_proto(&pool, params).await?; + let rows = fetch_all_rows( + &pool, + text, + "SELECT table_name as name FROM information_schema.views WHERE table_schema = ? ORDER BY table_name ASC", + &[db_name], + ) + .await?; let views: Vec = rows .iter() .map(|r| ViewInfo { @@ -685,10 +910,10 @@ pub async fn get_view_definition( view_name: &str, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let escaped_name = escape_identifier(view_name); let query = format!("SHOW CREATE VIEW `{}`", escaped_name); - let row = sqlx::query(&query) - .fetch_one(&pool) + let row = fetch_one_row(&pool, text, &query, &[]) .await .map_err(|e| format!("Failed to get view definition: {}", e))?; let definition = mysql_row_str(&row, 1); @@ -702,10 +927,10 @@ pub async fn create_view( definition: &str, ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let escaped_name = escape_identifier(view_name); let query = format!("CREATE VIEW `{}` AS {}", escaped_name, definition); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to create view: {}", e))?; Ok(()) @@ -717,10 +942,10 @@ pub async fn alter_view( definition: &str, ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let escaped_name = escape_identifier(view_name); let query = format!("ALTER VIEW `{}` AS {}", escaped_name, definition); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to alter view: {}", e))?; Ok(()) @@ -728,10 +953,10 @@ pub async fn alter_view( pub async fn drop_view(params: &ConnectionParams, view_name: &str) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let escaped_name = escape_identifier(view_name); let query = format!("DROP VIEW IF EXISTS `{}`", escaped_name); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to drop view: {}", e))?; Ok(()) @@ -745,6 +970,7 @@ pub async fn get_view_columns( let db_name = schema.unwrap_or_else(|| params.database.primary()); // Views in MySQL can be queried like tables for column info let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -753,12 +979,7 @@ pub async fn get_view_columns( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(view_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, view_name]).await?; Ok(rows .iter() @@ -801,6 +1022,7 @@ pub async fn get_routines( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT routine_name, routine_type, routine_definition FROM information_schema.routines @@ -808,11 +1030,7 @@ pub async fn get_routines( ORDER BY routine_name "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; Ok(rows .iter() @@ -831,6 +1049,7 @@ pub async fn get_routine_parameters( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; // 1. Get return type for functions from routines table let return_type_query = r#" @@ -839,12 +1058,8 @@ pub async fn get_routine_parameters( WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ? "#; - let routine_info = sqlx::query(return_type_query) - .bind(db_name) - .bind(routine_name) - .fetch_optional(&pool) - .await - .map_err(|e| e.to_string())?; + let routine_info = + fetch_optional_row(&pool, text, return_type_query, &[db_name, routine_name]).await?; let mut parameters = Vec::new(); @@ -871,12 +1086,7 @@ pub async fn get_routine_parameters( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(routine_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, routine_name]).await?; parameters.extend(rows.iter().map(|r| RoutineParameter { name: mysql_row_str(r, 0), @@ -894,16 +1104,14 @@ pub async fn get_routine_definition( routine_type: &str, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = format!( "SHOW CREATE {} `{}`", routine_type, escape_identifier(routine_name) ); - let row = sqlx::query(&query) - .fetch_one(&pool) - .await - .map_err(|e| e.to_string())?; + let row = fetch_one_row(&pool, text, &query, &[]).await?; let definition = mysql_row_str(&row, 2); @@ -956,6 +1164,7 @@ async fn exec_on_mysql_conn( query: &str, limit: Option, page: u32, + text: TextProto, ) -> Result { // Transaction-control statements have to bypass the prepared-statement // protocol — see `is_text_protocol_stmt`. They never return a result @@ -976,13 +1185,16 @@ async fn exec_on_mysql_conn( } // Non-result-set statements (INSERT / UPDATE / DELETE / DDL) go through - // `execute()` so we can return the actual `rows_affected`. + // `execute()` so we can return the actual `rows_affected`. In text mode + // they must use `raw_sql` (COM_QUERY) since the bastion rejects prepares. if !crate::drivers::common::returns_result_set(query) { use sqlx::Executor; - let exec_result = conn - .execute(sqlx::query(query)) - .await - .map_err(|e| e.to_string())?; + let exec_result = if text.enabled { + conn.execute(sqlx::raw_sql(query)).await + } else { + conn.execute(sqlx::query(query)).await + } + .map_err(|e| e.to_string())?; return Ok(QueryResult { columns: vec![], rows: vec![], @@ -1021,7 +1233,12 @@ async fn exec_on_mysql_conn( // Scope the stream so `conn` borrow is released before returning { use futures::stream::StreamExt; - let mut rows_stream = sqlx::query(&final_query).fetch(&mut *conn); + let mut rows_stream = if text.enabled { + use sqlx::Executor; + (&mut *conn).fetch(sqlx::raw_sql(&final_query)) + } else { + sqlx::query(&final_query).fetch(&mut *conn) + }; while let Some(result) = rows_stream.next().await { match result { @@ -1079,7 +1296,10 @@ pub async fn execute_query( schema: Option<&str>, ) -> Result { let mut conn = acquire_mysql_conn(params, schema).await?; - exec_on_mysql_conn(&mut *conn, query, limit, page).await + // `exec_on_mysql_conn` runs the user's SQL verbatim (no literal inlining), + // so it only needs to know whether to use the text protocol. + let text = TextProto::protocol_only(force_text_protocol(params)); + exec_on_mysql_conn(&mut *conn, query, limit, page, text).await } /// Runs a sequence of statements on a single pooled connection so that @@ -1100,10 +1320,13 @@ pub async fn execute_batch( on_progress: Option<&crate::drivers::driver_trait::BatchProgressFn>, ) -> Result, String> { let mut conn = acquire_mysql_conn(params, schema).await?; + // See `execute_query`: statements run verbatim, so only the protocol flag + // is needed here, not the literal-escaping mode. + let text = TextProto::protocol_only(force_text_protocol(params)); let mut results = Vec::with_capacity(queries.len()); for (idx, q) in queries.iter().enumerate() { let start = std::time::Instant::now(); - let outcome = exec_on_mysql_conn(&mut *conn, q, limit, page).await; + let outcome = exec_on_mysql_conn(&mut *conn, q, limit, page, text).await; let res = crate::models::BatchStatementResult::from_outcome(start, outcome); if let Some(cb) = on_progress { cb(idx, &res); @@ -1120,17 +1343,14 @@ pub async fn get_triggers( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching triggers for database: {}", db_name); let pool = get_mysql_pool(params).await?; + let text = resolve_text_proto(&pool, params).await?; let query = r#" SELECT trigger_name, event_object_table, event_manipulation, action_timing FROM information_schema.triggers WHERE trigger_schema = ? ORDER BY trigger_name ASC "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let triggers: Vec = rows .iter() .map(|r| TriggerInfo { @@ -1152,12 +1372,16 @@ pub async fn get_trigger_definition( ) -> Result { let pool = get_mysql_pool(params).await?; let qualified = match schema { - Some(s) => format!("`{}`.`{}`", escape_identifier(s), escape_identifier(trigger_name)), + Some(s) => format!( + "`{}`.`{}`", + escape_identifier(s), + escape_identifier(trigger_name) + ), None => format!("`{}`", escape_identifier(trigger_name)), }; let query = format!("SHOW CREATE TRIGGER {}", qualified); - let row = sqlx::query(&query) - .fetch_one(&pool) + let text = resolve_text_proto(&pool, params).await?; + let row = fetch_one_row(&pool, text, &query, &[]) .await .map_err(|e| format!("Failed to get trigger definition: {}", e))?; // SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ... @@ -1194,7 +1418,11 @@ pub async fn drop_trigger( ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; let qualified = match schema { - Some(s) => format!("`{}`.`{}`", escape_identifier(s), escape_identifier(trigger_name)), + Some(s) => format!( + "`{}`.`{}`", + escape_identifier(s), + escape_identifier(trigger_name) + ), None => format!("`{}`", escape_identifier(trigger_name)), }; let query = format!("DROP TRIGGER IF EXISTS {}", qualified); @@ -1361,10 +1589,8 @@ impl DatabaseDriver for MysqlDriver { } else { format!("{}:{}", user, encode(raw_pass)) }; - let max_allowed_packet = mysql_numeric_setting( - "maxAllowedPacket", - DEFAULT_MYSQL_MAX_ALLOWED_PACKET, - ); + let max_allowed_packet = + mysql_numeric_setting("maxAllowedPacket", DEFAULT_MYSQL_MAX_ALLOWED_PACKET); let socket_timeout = mysql_numeric_setting("socketTimeout", DEFAULT_MYSQL_SOCKET_TIMEOUT_MS); let connect_timeout = @@ -1398,9 +1624,11 @@ impl DatabaseDriver for MysqlDriver { ) -> Result<(), String> { use sqlx::{ConnectOptions, Connection}; // Route through `build_mysql_options` rather than the connection URL so - // MySQL-specific flags such as `pipes_as_concat` are honored: the sqlx - // URL parser silently ignores unknown query parameters, which would let - // a Vitess/PlanetScale connection pass the test but fail at pool time. + // MySQL-specific flags are honored: the sqlx URL parser silently ignores + // unknown query parameters, which would let a Vitess/PlanetScale + // connection pass the test but fail at pool time. Going through the + // builder also applies the cleartext plugin gating (mysql_clear_password + // requires an enforced TLS mode) to the test, exactly as for the live pool. let options = crate::pool_manager::build_mysql_options(params, None)?; match options.connect().await { Ok(mut conn) => { diff --git a/src-tauri/src/drivers/mysql/tests.rs b/src-tauri/src/drivers/mysql/tests.rs index 384bfb03..e9f57e2b 100644 --- a/src-tauri/src/drivers/mysql/tests.rs +++ b/src-tauri/src/drivers/mysql/tests.rs @@ -1,5 +1,6 @@ use super::build_mysql_pk_where; use super::explain::{parse_analyze_actual, parse_mysql_analyze_text, parse_mysql_query_block}; +use super::helpers::{inline_str_placeholders, mysql_bytes_literal, mysql_string_literal}; use super::MysqlDriver; use crate::drivers::driver_trait::DatabaseDriver; use crate::models::ExplainNode; @@ -37,6 +38,78 @@ fn build_connection_url_includes_disabled_ssl_mode() { assert!(url.contains("ssl-mode=disabled"), "url was: {url}"); } +// -- Text-protocol literal helpers (Warpgate / cleartext bastion path) ----- + +#[test] +fn mysql_string_literal_quotes_and_escapes() { + // Default sql_mode: backslash escapes enabled. + assert_eq!(mysql_string_literal("public", false), "'public'"); + assert_eq!(mysql_string_literal("o'brien", false), "'o\\'brien'"); + assert_eq!(mysql_string_literal("a\\b", false), "'a\\\\b'"); + assert_eq!(mysql_string_literal("line\nbreak", false), "'line\\nbreak'"); + assert_eq!(mysql_string_literal("", false), "''"); +} + +#[test] +fn mysql_string_literal_no_backslash_escapes_mode() { + // Under NO_BACKSLASH_ESCAPES the backslash is literal, so quotes are + // doubled and backslashes are left untouched. Escaping a single quote as + // `\'` (the default-mode form) would be mis-parsed here and is an + // injection vector — verify we use `''` instead. + assert_eq!(mysql_string_literal("public", true), "'public'"); + assert_eq!(mysql_string_literal("o'brien", true), "'o''brien'"); + assert_eq!(mysql_string_literal("a\\b", true), "'a\\b'"); + // A trailing backslash must not escape the closing quote. + assert_eq!(mysql_string_literal("ends\\", true), "'ends\\'"); + assert_eq!( + mysql_string_literal("' OR '1'='1", true), + "''' OR ''1''=''1'" + ); +} + +#[test] +fn mysql_bytes_literal_hex_encodes() { + assert_eq!(mysql_bytes_literal(&[]), "x''"); + assert_eq!(mysql_bytes_literal(&[0x00, 0x0f, 0xff]), "x'000fff'"); + assert_eq!(mysql_bytes_literal(b"AB"), "x'4142'"); +} + +#[test] +fn inline_str_placeholders_substitutes_in_order() { + let sql = "WHERE table_schema = ? AND table_name = ?"; + assert_eq!( + inline_str_placeholders(sql, &["mydb", "users"], false), + "WHERE table_schema = 'mydb' AND table_name = 'users'" + ); +} + +#[test] +fn inline_str_placeholders_escapes_injection_attempt() { + let sql = "WHERE table_schema = ?"; + assert_eq!( + inline_str_placeholders(sql, &["x' OR '1'='1"], false), + "WHERE table_schema = 'x\\' OR \\'1\\'=\\'1'" + ); + // Same payload under NO_BACKSLASH_ESCAPES: quotes are doubled. + assert_eq!( + inline_str_placeholders(sql, &["x' OR '1'='1"], true), + "WHERE table_schema = 'x'' OR ''1''=''1'" + ); +} + +#[test] +fn inline_str_placeholders_leaves_extra_placeholders() { + // Fewer binds than placeholders: the surplus `?` stays untouched. + assert_eq!( + inline_str_placeholders("a = ? AND b = ?", &["1"], false), + "a = '1' AND b = ?" + ); + assert_eq!( + inline_str_placeholders("no params here", &[], false), + "no params here" + ); +} + /// Helper: parse a MariaDB ANALYZE FORMAT=JSON string and return the root node. fn parse_json(json: &str) -> ExplainNode { let val: serde_json::Value = serde_json::from_str(json).expect("invalid JSON"); @@ -573,8 +646,7 @@ fn parse_analyze_actual_multiplies_per_loop_time_by_loops() { #[test] fn parse_analyze_actual_single_loop_is_unchanged() { - let (time_ms, _, loops) = - parse_analyze_actual(" (actual time=0.10..0.42 rows=5 loops=1)"); + let (time_ms, _, loops) = parse_analyze_actual(" (actual time=0.10..0.42 rows=5 loops=1)"); assert_eq!(loops, Some(1)); assert!((time_ms.unwrap() - 0.42).abs() < 1e-9); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eca9d7aa..f33e1551 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -131,6 +131,10 @@ pub struct ConnectionParams { pub ssl_ca: Option, pub ssl_cert: Option, pub ssl_key: Option, + // MySQL/MariaDB: enable the mysql_clear_password (cleartext) auth plugin. + // Required by bastions like Warpgate. Only honoured over a TLS connection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_cleartext_plugin: Option, // MySQL: whether sqlx should force the PIPES_AS_CONCAT / NO_ENGINE_SUBSTITUTION // sql_mode on connect. Defaults to `true` (sqlx's behavior) when unset. // Set to `false` for servers that reject altering sql_mode, e.g. Vitess/PlanetScale. diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 7806a9a0..30808fb2 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -822,6 +822,7 @@ mod tests { ssl_ca: None, ssl_cert: None, ssl_key: None, + enable_cleartext_plugin: None, pipes_as_concat: None, ssh_enabled: None, ssh_connection_id: None, diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 2b88c1e0..0bfe3462 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -8,7 +8,7 @@ use rustls::crypto::verify_tls13_signature; use rustls::crypto::CryptoProvider; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::server::ParsedCertificate; -use rustls::{DigitallySignedStruct}; +use rustls::DigitallySignedStruct; use rustls::{ClientConfig, Error as TlsError, RootCertStore}; use rustls_platform_verifier::BuilderVerifierExt; use sha2::{Digest, Sha256}; @@ -96,11 +96,15 @@ pub(crate) fn build_connection_key( ) -> String { let tls_key = match params.driver.as_str() { "mysql" => Some(format!( - "ssl:{}:{}:{}:{}:pipes:{}", + // `clear` keeps cleartext and non-cleartext connections to the same + // host in separate pools — they authenticate differently. `pipes` + // likewise separates pools that force sql_mode from those that don't. + "ssl:{}:{}:{}:{}:clear:{}:pipes:{}", params.ssl_mode.as_deref().unwrap_or("default"), params.ssl_ca.as_deref().unwrap_or(""), params.ssl_cert.as_deref().unwrap_or(""), params.ssl_key.as_deref().unwrap_or(""), + params.enable_cleartext_plugin.unwrap_or(false), params.pipes_as_concat.unwrap_or(true) )), "postgres" => { @@ -118,12 +122,17 @@ pub(crate) fn build_connection_key( // Include database in key so different databases on the same connection use separate pools format!("{}:conn:{}:{}", params.driver, conn_id, params.database) } else { - // Fall back to host:port:database for ad-hoc connections + // Fall back to host:port:user:database for ad-hoc connections (no saved + // id). The username is essential: bastions like Warpgate multiplex many + // targets behind a single host:port and pick the backend from the + // username, so without it two different targets would share one pool and + // serve each other's databases. format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", params.driver, params.host.as_deref().unwrap_or("localhost"), params.port.unwrap_or(0), + params.username.as_deref().unwrap_or(""), params.database ) }; @@ -194,6 +203,27 @@ pub(crate) fn build_mysql_options( options = options.ssl_client_key(key); } + // Optionally enable the mysql_clear_password (cleartext) auth plugin, used by + // bastions like Warpgate. Cleartext credentials must never be sent over an + // unencrypted link, so require a TLS mode that actually guarantees + // encryption. `Preferred` only attempts TLS and silently falls back to + // plaintext, so it is rejected alongside `Disabled`. + if params.enable_cleartext_plugin.unwrap_or(false) { + if !matches!( + ssl_mode, + MySqlSslMode::Required | MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity + ) { + return Err( + "Cleartext password plugin requires an enforced TLS/SSL mode \ + (Required, Verify CA, or Verify Identity). Preferred is not enough \ + because it can silently fall back to an unencrypted connection. \ + Refusing to send the password in cleartext." + .to_string(), + ); + } + options = options.enable_cleartext_plugin(true); + } + // By default sqlx forces `SET sql_mode=(... ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION')` // on every connection. Vitess/PlanetScale reject altering these modes, so allow // opting out per connection. When disabled, no `SET sql_mode` is issued at all. @@ -434,8 +464,7 @@ struct NoCertVerifier { impl NoCertVerifier { fn new() -> Self { - let provider = CryptoProvider::get_default() - .expect("rustls CryptoProvider not installed"); + let provider = CryptoProvider::get_default().expect("rustls CryptoProvider not installed"); Self { supported: provider.signature_verification_algorithms, } @@ -494,16 +523,13 @@ struct VerifyCaCertVerifier { impl VerifyCaCertVerifier { fn new(roots: RootCertStore) -> Result { if roots.is_empty() { - return Err( - "No root certificates available. For verify-ca mode, \ + return Err("No root certificates available. For verify-ca mode, \ you must specify an explicit CA file via the connection's \ CA Certificate field. On macOS, the system keychain does \ not provide root anchors compatible with strict EKU checks." - .to_string(), - ); + .to_string()); } - let provider = CryptoProvider::get_default() - .ok_or("No rustls CryptoProvider installed")?; + let provider = CryptoProvider::get_default().ok_or("No rustls CryptoProvider installed")?; Ok(Self { roots: Arc::new(roots), supported: provider.signature_verification_algorithms, diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 971af020..1b335db0 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -171,6 +171,22 @@ mod tests { )); } + #[test] + fn adhoc_mysql_pool_key_changes_when_username_changes() { + // No connection_id → ad-hoc key. Bastions like Warpgate share one + // host:port across targets and select the backend by username, so two + // usernames must never resolve to the same pool. + let mut alice = mysql_params("required"); + alice.username = Some("alice".to_string()); + let mut bob = mysql_params("required"); + bob.username = Some("bob".to_string()); + + assert_ne!( + build_connection_key(&alice, None), + build_connection_key(&bob, None) + ); + } + #[test] fn mysql_options_default_force_pipes_as_concat() { // Unset => keep sqlx's default behavior (force the sql_mode). @@ -184,6 +200,19 @@ mod tests { ); } + #[test] + fn mysql_pool_key_changes_when_cleartext_plugin_changes() { + let mut plain = mysql_params("required"); + plain.enable_cleartext_plugin = Some(false); + let mut cleartext = mysql_params("required"); + cleartext.enable_cleartext_plugin = Some(true); + + assert_ne!( + build_connection_key(&plain, Some("conn-1")), + build_connection_key(&cleartext, Some("conn-1")) + ); + } + #[test] fn mysql_options_disable_pipes_as_concat_for_vitess() { // Some(false) => do not force the sql_mode (Vitess/PlanetScale). @@ -198,6 +227,37 @@ mod tests { ); } + #[test] + fn cleartext_plugin_rejected_without_tls() { + let mut params = mysql_params("disabled"); + params.enable_cleartext_plugin = Some(true); + + assert!(build_mysql_options(¶ms, None).is_err()); + } + + #[test] + fn cleartext_plugin_rejected_with_preferred_tls() { + // `Preferred` only attempts TLS and silently falls back to plaintext, + // so cleartext credentials could still cross an unencrypted link. + let mut params = mysql_params("preferred"); + params.enable_cleartext_plugin = Some(true); + + assert!(build_mysql_options(¶ms, None).is_err()); + } + + #[test] + fn cleartext_plugin_allowed_with_enforced_tls() { + for mode in ["required", "verify_ca", "verify_identity"] { + let mut params = mysql_params(mode); + params.enable_cleartext_plugin = Some(true); + + assert!( + build_mysql_options(¶ms, None).is_ok(), + "cleartext should be allowed with enforced TLS mode {mode}" + ); + } + } + #[test] fn mysql_pool_key_differs_on_pipes_as_concat() { let forced = mysql_params("required"); diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 0201dd02..b596256d 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -60,6 +60,8 @@ interface ConnectionParams { ssl_ca?: string; ssl_cert?: string; ssl_key?: string; + // MySQL/MariaDB: mysql_clear_password (cleartext) auth plugin (TLS required) + enable_cleartext_plugin?: boolean; // MySQL: force PIPES_AS_CONCAT / NO_ENGINE_SUBSTITUTION sql_mode on connect. // Defaults to true; disable for Vitess/PlanetScale which reject altering sql_mode. pipes_as_concat?: boolean; @@ -1429,7 +1431,13 @@ export const NewConnectionModal = ({ verify_identity: t("newConnection.sslModes.verify_identity", { defaultValue: "Verify Identity" }), } } - onChange={(v) => updateField("ssl_mode", v)} + onChange={(v) => { + updateField("ssl_mode", v); + // Cleartext auth must never go over an unencrypted link. + if (driver === "mysql" && v === "disabled") { + updateField("enable_cleartext_plugin", false); + } + }} searchable={false} /> @@ -1558,6 +1566,55 @@ export const NewConnectionModal = ({ )} + + {/* Cleartext password plugin (MySQL/MariaDB only) */} + {driver === "mysql" && + (() => { + const effectiveSslMode = formData.ssl_mode || "required"; + // Cleartext credentials must travel over enforced TLS. `preferred` + // only attempts TLS and can silently fall back to plaintext, so it is + // gated off here to match the backend (build_mysql_options). + const tlsOff = !["required", "verify_ca", "verify_identity"].includes( + effectiveSslMode, + ); + return ( +
+ +

+ {tlsOff + ? t("newConnection.enableCleartextPluginTlsRequired", { + defaultValue: + "Select an enforced TLS mode above (Required, Verify CA, or Verify Identity) to use the cleartext password plugin.", + }) + : t("newConnection.enableCleartextPluginHint", { + defaultValue: + "Sends the password using mysql_clear_password. Required for bastions like Warpgate. Only used over a TLS connection.", + })} +

+
+ ); + })()} ); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9a6a43df..2c71b48c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -678,6 +678,9 @@ "manageSshConnections": "SSH-Verbindungen verwalten", "noSshConnections": "Keine SSH-Verbindungen verfügbar", "sslMode": "SSL-Modus", + "enableCleartextPlugin": "Klartext-Passwort-Plugin aktivieren", + "enableCleartextPluginHint": "Sendet das Passwort über mysql_clear_password. Erforderlich für Bastions wie Warpgate. Wird nur über eine TLS-Verbindung verwendet.", + "enableCleartextPluginTlsRequired": "Wählen Sie oben einen erzwungenen TLS-Modus (Required, Verify CA oder Verify Identity), um das Klartext-Passwort-Plugin zu verwenden.", "sslModes": { "disable": "Deaktivieren", "allow": "Erlauben", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6ca3ce8a..85fb24fc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -712,6 +712,9 @@ "manageSshConnections": "Manage SSH Connections", "noSshConnections": "No SSH connections available", "sslMode": "SSL Mode", + "enableCleartextPlugin": "Enable cleartext password plugin", + "enableCleartextPluginHint": "Sends the password using mysql_clear_password. Required for bastions like Warpgate. Only used over a TLS connection.", + "enableCleartextPluginTlsRequired": "Select an enforced TLS mode above (Required, Verify CA, or Verify Identity) to use the cleartext password plugin.", "sslModes": { "disable": "Disable", "allow": "Allow", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e7a83378..ac679ce7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -683,6 +683,9 @@ "manageSshConnections": "Gestionar Conexiones SSH", "noSshConnections": "No hay conexiones SSH disponibles", "sslMode": "Modo SSL", + "enableCleartextPlugin": "Habilitar el complemento de contraseña en texto plano", + "enableCleartextPluginHint": "Envía la contraseña usando mysql_clear_password. Necesario para bastiones como Warpgate. Solo se usa en una conexión TLS.", + "enableCleartextPluginTlsRequired": "Selecciona arriba un modo TLS forzado (Required, Verify CA o Verify Identity) para usar el complemento de contraseña en texto plano.", "sslModes": { "disable": "Desactivado", "allow": "Permitir", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index daa1d557..2420cb4f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -678,6 +678,9 @@ "manageSshConnections": "Gérer les connexions SSH", "noSshConnections": "Aucune connexion SSH disponible", "sslMode": "Mode SSL", + "enableCleartextPlugin": "Activer le plugin de mot de passe en clair", + "enableCleartextPluginHint": "Envoie le mot de passe via mysql_clear_password. Requis pour les bastions comme Warpgate. Utilisé uniquement sur une connexion TLS.", + "enableCleartextPluginTlsRequired": "Sélectionnez un mode TLS strict ci-dessus (Required, Verify CA ou Verify Identity) pour utiliser le plugin de mot de passe en clair.", "sslModes": { "disable": "Désactiver", "allow": "Autoriser", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index a244887b..5b7df5ff 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -683,6 +683,9 @@ "manageSshConnections": "Gestisci Connessioni SSH", "noSshConnections": "Nessuna connessione SSH disponibile", "sslMode": "Modalità SSL", + "enableCleartextPlugin": "Abilita il plugin password in chiaro", + "enableCleartextPluginHint": "Invia la password tramite mysql_clear_password. Richiesto per bastion come Warpgate. Usato solo su una connessione TLS.", + "enableCleartextPluginTlsRequired": "Seleziona sopra una modalità TLS rigorosa (Required, Verify CA o Verify Identity) per usare il plugin password in chiaro.", "sslModes": { "disable": "Disabilitato", "allow": "Permetti", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 541ce3e2..9914178f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -692,6 +692,9 @@ "manageSshConnections": "SSH 接続を管理", "noSshConnections": "利用可能な SSH 接続がありません", "sslMode": "SSL モード", + "enableCleartextPlugin": "クリアテキストパスワードプラグインを有効にする", + "enableCleartextPluginHint": "mysql_clear_password を使用してパスワードを送信します。Warpgate などのバスティオンで必要です。TLS 接続でのみ使用されます。", + "enableCleartextPluginTlsRequired": "クリアテキストパスワードプラグインを使用するには、上で強制 TLS モード(Required、Verify CA、または Verify Identity)を選択してください。", "sslModes": { "disable": "無効", "allow": "許可", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8483a021..54cc92ab 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -672,6 +672,9 @@ "manageSshConnections": "Управление SSH-подключениями", "noSshConnections": "Нет доступных SSH-подключений", "sslMode": "Режим SSL", + "enableCleartextPlugin": "Включить плагин пароля в открытом виде", + "enableCleartextPluginHint": "Отправляет пароль через mysql_clear_password. Требуется для бастионов, таких как Warpgate. Используется только при TLS-соединении.", + "enableCleartextPluginTlsRequired": "Выберите выше строгий режим TLS (Required, Verify CA или Verify Identity), чтобы использовать плагин пароля в открытом виде.", "sslModes": { "disable": "Отключён", "allow": "Разрешён", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cac50fd9..059d7dfb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -646,6 +646,9 @@ "manageSshConnections": "管理 SSH 连接", "noSshConnections": "无 SSH 连接可用", "sslMode": "SSL 模式", + "enableCleartextPlugin": "启用明文密码插件", + "enableCleartextPluginHint": "使用 mysql_clear_password 发送密码。Warpgate 等堡垒机需要此选项。仅在 TLS 连接上使用。", + "enableCleartextPluginTlsRequired": "请在上方选择强制 TLS 模式(Required、Verify CA 或 Verify Identity)以使用明文密码插件。", "sslModes": { "disable": "禁用", "allow": "允许",