Skip to content

Commit 00baf2c

Browse files
authored
Merge pull request #42 from pmorris-dev/vulnerabilities-audit
Security hardening: path traversal, resource limits, and documentation
2 parents 91b7379 + 376bc79 commit 00baf2c

18 files changed

Lines changed: 619 additions & 75 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,41 @@ Working Tauri demo apps are in the [`examples/`](examples) directory:
948948
See the [toolkit crate README](crates/sqlx-sqlite-toolkit/README.md#examples)
949949
for setup instructions.
950950

951+
## Security Considerations
952+
953+
### Cross-Window Shared State
954+
955+
Database instances are shared across all webviews/windows within the same Tauri
956+
application. A database loaded in one window is accessible from any other window
957+
without calling `load()` again. Writes from one window are immediately visible
958+
to reads in another, and closing a database affects all windows.
959+
960+
### Resource Limits
961+
962+
The plugin enforces several resource limits to prevent denial-of-service from
963+
untrusted or buggy frontend code:
964+
965+
* **Database count**: Maximum 50 concurrently loaded databases (configurable
966+
via `Builder::max_databases()`)
967+
* **Interruptible transaction timeout**: Transactions that exceed the
968+
default (5 minutes) are automatically rolled back on the next access
969+
attempt (configurable via `Builder::transaction_timeout()`)
970+
* **Observer channel capacity**: Capped at 10,000 (default 256)
971+
* **Observed tables**: Maximum 100 tables per `observe()` call
972+
* **Subscriptions**: Maximum 100 active subscriptions per database
973+
974+
### Unbounded Result Sets
975+
976+
`fetchAll()` returns the entire result set in a single response with no built-in
977+
size limit. For large or unbounded queries, prefer `fetchPage()` with keyset
978+
pagination to keep memory usage bounded on both the Rust and TypeScript sides.
979+
980+
### Path Validation
981+
982+
Database paths are validated to prevent directory traversal. Absolute paths,
983+
`..` segments, and null bytes are rejected. All paths are resolved relative to
984+
the app config directory.
985+
951986
## Development
952987

953988
This project follows

crates/sqlx-sqlite-conn-mgr/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "sqlx-sqlite-conn-mgr"
33
# Sync major.minor with major.minor of SQLx crate
4-
version = "0.8.6"
4+
version = "0.8.7"
55
description = "Wraps SQLx for SQLite, enforcing pragmatic connection policies for mobile and desktop applications"
66
authors = ["Jeremy Thomerson"]
77
license = "MIT"

crates/sqlx-sqlite-conn-mgr/src/attached.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ impl AttachedReadConnection {
7070
/// attached databases may persist when the connection is returned to the pool.
7171
pub async fn detach_all(mut self) -> Result<()> {
7272
for schema_name in &self.schema_names {
73-
let detach_sql = format!("DETACH DATABASE {}", schema_name);
73+
let detach_sql = format!("DETACH DATABASE \"{}\"", schema_name);
7474
sqlx::query(&detach_sql).execute(&mut *self.conn).await?;
7575
}
7676
Ok(())
@@ -140,7 +140,7 @@ impl AttachedWriteGuard {
140140
/// attached databases may persist when the connection is returned to the pool.
141141
pub async fn detach_all(mut self) -> Result<()> {
142142
for schema_name in &self.schema_names {
143-
let detach_sql = format!("DETACH DATABASE {}", schema_name);
143+
let detach_sql = format!("DETACH DATABASE \"{}\"", schema_name);
144144
sqlx::query(&detach_sql).execute(&mut *self.writer).await?;
145145
}
146146
Ok(())
@@ -252,7 +252,10 @@ pub async fn acquire_reader_with_attached(
252252
// Schema name is validated above to contain only safe identifier characters
253253
let path = spec.database.path_str();
254254
let escaped_path = path.replace("'", "''");
255-
let attach_sql = format!("ATTACH DATABASE '{}' AS {}", escaped_path, spec.schema_name);
255+
let attach_sql = format!(
256+
"ATTACH DATABASE '{}' AS \"{}\"",
257+
escaped_path, spec.schema_name
258+
);
256259
sqlx::query(&attach_sql).execute(&mut *conn).await?;
257260

258261
schema_names.push(spec.schema_name);
@@ -349,7 +352,10 @@ pub async fn acquire_writer_with_attached(
349352
for spec in specs {
350353
let path = spec.database.path_str();
351354
let escaped_path = path.replace("'", "''");
352-
let attach_sql = format!("ATTACH DATABASE '{}' AS {}", escaped_path, spec.schema_name);
355+
let attach_sql = format!(
356+
"ATTACH DATABASE '{}' AS \"{}\"",
357+
escaped_path, spec.schema_name
358+
);
353359
sqlx::query(&attach_sql).execute(&mut *writer).await?;
354360

355361
schema_names.push(spec.schema_name);

crates/sqlx-sqlite-observer/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "sqlx-sqlite-observer"
33
# Sync major.minor with major.minor of SQLx crate
4-
version = "0.8.6"
4+
version = "0.8.7"
55
license = "MIT"
66
edition = "2024"
77
rust-version = "1.89"
@@ -29,7 +29,7 @@ regex = "1.12.3"
2929
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"], default-features = false }
3030
# Required for preupdate_hook - SQLite must be compiled with SQLITE_ENABLE_PREUPDATE_HOOK
3131
libsqlite3-sys = { version = "0.30.1", features = ["preupdate_hook"] }
32-
sqlx-sqlite-conn-mgr = { path = "../sqlx-sqlite-conn-mgr", version = "0.8.6", optional = true }
32+
sqlx-sqlite-conn-mgr = { path = "../sqlx-sqlite-conn-mgr", version = "0.8.7", optional = true }
3333

3434
[dev-dependencies]
3535
tokio = { version = "1.49.0", features = ["full", "macros"] }

crates/sqlx-sqlite-observer/src/broker.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,16 @@ pub struct ObservationBroker {
5959

6060
impl ObservationBroker {
6161
/// Creates a new broker with the specified broadcast channel capacity.
62+
///
63+
/// # Panics
64+
///
65+
/// Panics if `channel_capacity` is 0.
6266
pub fn new(channel_capacity: usize, capture_values: bool) -> Arc<Self> {
67+
// broadcast::channel panics on zero capacity. Assert here to surface a clear
68+
// message rather than an internal tokio panic. Changing the return type to
69+
// Result would ripple through every call site for a case that the plugin layer
70+
// already validates before reaching this point.
71+
assert!(channel_capacity > 0, "channel_capacity must be at least 1");
6372
let (change_tx, _) = broadcast::channel(channel_capacity);
6473
Arc::new(Self {
6574
buffer: Mutex::new(Vec::new()),

crates/sqlx-sqlite-observer/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ impl ObserverConfig {
9090

9191
/// Sets the broadcast channel capacity for change notifications.
9292
///
93+
/// Capacity must be at least 1. A capacity of 0 will cause a panic when the
94+
/// observer is initialized.
95+
///
9396
/// See [`channel_capacity`](Self::channel_capacity) for details on sizing.
9497
pub fn with_channel_capacity(mut self, capacity: usize) -> Self {
9598
self.channel_capacity = capacity;

crates/sqlx-sqlite-observer/src/schema.rs

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ pub async fn query_table_info(
2020
// Check if table exists and get WITHOUT ROWID status
2121
let without_rowid = is_without_rowid(conn, table_name).await?;
2222

23-
// Get primary key columns using PRAGMA table_info
23+
// Get primary key columns using pragma_table_info()
2424
let pk_columns = query_pk_columns(conn, table_name).await?;
2525

2626
// Determine if table exists:
27-
// - If pk_columns is None, PRAGMA table_info returned no rows (table doesn't exist)
27+
// - If pk_columns is None, pragma_table_info returned no rows (table doesn't exist)
2828
// - If without_rowid is true, the table must exist (we found it in sqlite_master)
2929
// - A table with no explicit PK returns Some([]), not None
3030
if pk_columns.is_none() && !without_rowid {
@@ -78,15 +78,20 @@ fn has_without_rowid_clause(create_sql: &str) -> bool {
7878
/// Returns column indices in the order they appear in the PRIMARY KEY definition.
7979
/// For composite primary keys, the `pk` column in PRAGMA table_info indicates
8080
/// the position (1-indexed) within the PK.
81+
///
82+
/// Uses the `pragma_table_info()` table-valued function (available since SQLite
83+
/// 3.16.0) so the table name can be bound as a parameter instead of interpolated
84+
/// into the SQL string.
8185
async fn query_pk_columns(
8286
conn: &mut SqliteConnection,
8387
table_name: &str,
8488
) -> crate::Result<Option<Vec<usize>>> {
85-
// PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
89+
// pragma_table_info returns: cid, name, type, notnull, dflt_value, pk
8690
// pk is 0 for non-PK columns, or 1-indexed position for PK columns
87-
let pragma = format!("PRAGMA table_info({})", quote_identifier(table_name));
91+
let sql = "SELECT cid, name, type, \"notnull\", dflt_value, pk FROM pragma_table_info(?1)";
8892

89-
let rows = sqlx::query(&pragma)
93+
let rows = sqlx::query(sql)
94+
.bind(table_name)
9095
.fetch_all(&mut *conn)
9196
.await
9297
.map_err(crate::Error::Sqlx)?;
@@ -116,23 +121,10 @@ async fn query_pk_columns(
116121
Ok(Some(pk_columns.into_iter().map(|(cid, _)| cid).collect()))
117122
}
118123

119-
/// Quotes a SQLite identifier to prevent SQL injection.
120-
fn quote_identifier(name: &str) -> String {
121-
// Double any existing double quotes and wrap in double quotes
122-
format!("\"{}\"", name.replace('"', "\"\""))
123-
}
124-
125124
#[cfg(test)]
126125
mod tests {
127126
use super::*;
128127

129-
#[test]
130-
fn test_quote_identifier() {
131-
assert_eq!(quote_identifier("users"), "\"users\"");
132-
assert_eq!(quote_identifier("my table"), "\"my table\"");
133-
assert_eq!(quote_identifier("foo\"bar"), "\"foo\"\"bar\"");
134-
}
135-
136128
#[test]
137129
fn test_has_without_rowid_clause() {
138130
// Positive cases

crates/sqlx-sqlite-toolkit/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "sqlx-sqlite-toolkit"
33
# Sync major.minor with major.minor of SQLx crate
4-
version = "0.8.6"
4+
version = "0.8.7"
55
license = "MIT"
66
edition = "2024"
77
rust-version = "1.89"

crates/sqlx-sqlite-toolkit/src/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ pub enum Error {
4545
#[error("invalid transaction token")]
4646
InvalidTransactionToken,
4747

48+
/// Transaction timed out (exceeded the configured timeout).
49+
#[error("transaction timed out for database: {0}")]
50+
TransactionTimedOut(String),
51+
4852
/// Error from the observer (change notifications).
4953
#[cfg(feature = "observer")]
5054
#[error(transparent)]
@@ -115,6 +119,7 @@ impl Error {
115119
Error::TransactionAlreadyActive(_) => "TRANSACTION_ALREADY_ACTIVE".to_string(),
116120
Error::NoActiveTransaction(_) => "NO_ACTIVE_TRANSACTION".to_string(),
117121
Error::InvalidTransactionToken => "INVALID_TRANSACTION_TOKEN".to_string(),
122+
Error::TransactionTimedOut(_) => "TRANSACTION_TIMED_OUT".to_string(),
118123
#[cfg(feature = "observer")]
119124
Error::Observer(_) => "OBSERVER_ERROR".to_string(),
120125
Error::Io(_) => "IO_ERROR".to_string(),
@@ -194,6 +199,13 @@ mod tests {
194199
assert_eq!(err.error_code(), "IO_ERROR");
195200
}
196201

202+
#[test]
203+
fn test_error_code_transaction_timed_out() {
204+
let err = Error::TransactionTimedOut("test.db".into());
205+
assert_eq!(err.error_code(), "TRANSACTION_TIMED_OUT");
206+
assert!(err.to_string().contains("test.db"));
207+
}
208+
197209
#[test]
198210
fn test_error_code_other() {
199211
let err = Error::Other("something went wrong".into());

0 commit comments

Comments
 (0)