Skip to content

Commit e58189d

Browse files
pmorris-devclaude
andcommitted
fix: prevent path traversal in database path resolution
resolve_database_path() passed user input directly to PathBuf::join() without validation. Since join() replaces the base on absolute paths and preserves ".." segments, a malicious path could escape the app config directory. Add multi-layer validation in validate_and_resolve(): reject null bytes, absolute paths, and parent-directory components, then canonicalize and verify containment. In-memory database paths are passed through unchanged. Refactor resolve_migration_path() to delegate to resolve_database_path() so all entry points share the same validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e450e2e commit e58189d

3 files changed

Lines changed: 184 additions & 14 deletions

File tree

src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ pub enum Error {
2727
#[error("invalid database path: {0}")]
2828
InvalidPath(String),
2929

30+
/// Path traversal attempt detected.
31+
#[error("path traversal not allowed: {0}")]
32+
PathTraversal(String),
33+
3034
/// Attempted to access a database that hasn't been loaded.
3135
#[error("database {0} not loaded")]
3236
DatabaseNotLoaded(String),
@@ -67,6 +71,7 @@ impl Error {
6771
Error::Toolkit(e) => e.error_code(),
6872
Error::Migration(_) => "MIGRATION_ERROR".to_string(),
6973
Error::InvalidPath(_) => "INVALID_PATH".to_string(),
74+
Error::PathTraversal(_) => "PATH_TRAVERSAL".to_string(),
7075
Error::DatabaseNotLoaded(_) => "DATABASE_NOT_LOADED".to_string(),
7176
Error::ObservationNotEnabled(_) => "OBSERVATION_NOT_ENABLED".to_string(),
7277
Error::Other(_) => "ERROR".to_string(),

src/lib.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -473,17 +473,13 @@ fn emit_migration_event<R: Runtime>(
473473
}
474474
}
475475

476-
/// Resolve database path for migrations (similar to wrapper but accessible at init).
476+
/// Resolve database path for migrations.
477+
///
478+
/// Delegates to `resolve::resolve_database_path` to ensure consistent path validation
479+
/// across all entry points.
477480
fn resolve_migration_path<R: Runtime>(
478481
path: &str,
479482
app: &tauri::AppHandle<R>,
480483
) -> Result<std::path::PathBuf> {
481-
let app_path = app
482-
.path()
483-
.app_config_dir()
484-
.map_err(|_| Error::InvalidPath("No app config path found".to_string()))?;
485-
486-
std::fs::create_dir_all(&app_path)?;
487-
488-
Ok(app_path.join(path))
484+
crate::resolve::resolve_database_path(path, app)
489485
}

src/resolve.rs

Lines changed: 174 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::fs::create_dir_all;
2-
use std::path::PathBuf;
2+
use std::path::{Component, Path, PathBuf};
33

44
use sqlx_sqlite_conn_mgr::SqliteDatabaseConfig;
55
use sqlx_sqlite_toolkit::DatabaseWrapper;
@@ -23,8 +23,11 @@ pub async fn connect<R: Runtime>(
2323

2424
/// Resolve database file path relative to app config directory.
2525
///
26-
/// Paths are joined to `app_config_dir()` (e.g., `Library/Application Support/${bundleIdentifier}` on iOS).
27-
/// Special paths like `:memory:` are passed through unchanged.
26+
/// Paths are joined to `app_config_dir()` (e.g., `Library/Application Support/${bundleIdentifier}`
27+
/// on iOS). Special paths like `:memory:` are passed through unchanged.
28+
///
29+
/// Returns `Err(Error::PathTraversal)` if the path attempts to escape the app config directory
30+
/// via absolute paths, `..` segments, or null bytes.
2831
pub fn resolve_database_path<R: Runtime>(path: &str, app: &AppHandle<R>) -> Result<PathBuf, Error> {
2932
let app_path = app
3033
.path()
@@ -33,6 +36,172 @@ pub fn resolve_database_path<R: Runtime>(path: &str, app: &AppHandle<R>) -> Resu
3336

3437
create_dir_all(&app_path)?;
3538

36-
// Join the relative path to the app config directory
37-
Ok(app_path.join(path))
39+
validate_and_resolve(path, &app_path)
40+
}
41+
42+
/// Validate a user-supplied path and resolve it against a base directory.
43+
///
44+
/// In-memory database paths are passed through unchanged. All other paths are validated
45+
/// to ensure they cannot escape the base directory.
46+
fn validate_and_resolve(path: &str, base: &Path) -> Result<PathBuf, Error> {
47+
// Pass through in-memory database paths unchanged — they don't touch the filesystem.
48+
// Matches the same patterns as `is_memory_database` in sqlx-sqlite-conn-mgr.
49+
if is_memory_path(path) {
50+
return Ok(PathBuf::from(path));
51+
}
52+
53+
// Reject null bytes — these can truncate paths in C-level filesystem calls
54+
if path.contains('\0') {
55+
return Err(Error::PathTraversal("path contains null byte".to_string()));
56+
}
57+
58+
let rel = Path::new(path);
59+
60+
// Reject absolute paths — PathBuf::join replaces the base when given an absolute path
61+
if rel.is_absolute() {
62+
return Err(Error::PathTraversal(
63+
"absolute paths are not allowed".to_string(),
64+
));
65+
}
66+
67+
// Reject parent directory components — prevents escaping the base via `../`
68+
for component in rel.components() {
69+
if matches!(component, Component::ParentDir) {
70+
return Err(Error::PathTraversal(
71+
"parent directory references are not allowed".to_string(),
72+
));
73+
}
74+
}
75+
76+
// Join and canonicalize to verify containment. The parent directory is canonicalized
77+
// because the file may not exist yet.
78+
let joined = base.join(rel);
79+
let canonical_base = base
80+
.canonicalize()
81+
.map_err(|e| Error::InvalidPath(format!("cannot canonicalize base path: {e}")))?;
82+
83+
let canonical_resolved = if joined.exists() {
84+
joined.canonicalize()
85+
} else {
86+
// Canonicalize the parent and re-append the file name
87+
joined
88+
.parent()
89+
.ok_or_else(|| Error::InvalidPath("path has no parent".to_string()))?
90+
.canonicalize()
91+
.map(|parent| parent.join(joined.file_name().unwrap_or_default()))
92+
}
93+
.map_err(|e| Error::InvalidPath(format!("cannot canonicalize path: {e}")))?;
94+
95+
if !canonical_resolved.starts_with(&canonical_base) {
96+
return Err(Error::PathTraversal(
97+
"resolved path escapes the base directory".to_string(),
98+
));
99+
}
100+
101+
// Return the original (non-canonicalized) joined path for consistency with how the
102+
// rest of the codebase references database paths.
103+
Ok(joined)
104+
}
105+
106+
/// Check if a path string represents an in-memory SQLite database.
107+
///
108+
/// Matches the same patterns as `is_memory_database` in `sqlx-sqlite-conn-mgr`:
109+
/// `:memory:`, `file::memory:*` URIs, and `mode=memory` query parameters.
110+
fn is_memory_path(path: &str) -> bool {
111+
path == ":memory:"
112+
|| path.starts_with("file::memory:")
113+
|| (path.starts_with("file:") && path.contains("mode=memory"))
114+
}
115+
116+
#[cfg(test)]
117+
mod tests {
118+
use super::*;
119+
use std::fs;
120+
121+
/// Helper that creates a temporary base directory for testing.
122+
fn make_temp_base() -> PathBuf {
123+
let dir = std::env::temp_dir().join(format!("tauri_sqlite_test_{}", std::process::id()));
124+
fs::create_dir_all(&dir).unwrap();
125+
dir
126+
}
127+
128+
#[test]
129+
fn test_simple_filename() {
130+
let base = make_temp_base();
131+
let result = validate_and_resolve("mydb.db", &base).unwrap();
132+
assert_eq!(result, base.join("mydb.db"));
133+
}
134+
135+
#[test]
136+
fn test_subdirectory_path() {
137+
let base = make_temp_base();
138+
// Create the subdirectory so canonicalize can resolve it
139+
fs::create_dir_all(base.join("subdir")).unwrap();
140+
let result = validate_and_resolve("subdir/mydb.db", &base).unwrap();
141+
assert_eq!(result, base.join("subdir/mydb.db"));
142+
}
143+
144+
#[test]
145+
fn test_memory_passthrough() {
146+
let base = make_temp_base();
147+
assert_eq!(
148+
validate_and_resolve(":memory:", &base).unwrap(),
149+
PathBuf::from(":memory:"),
150+
);
151+
}
152+
153+
#[test]
154+
fn test_file_memory_uri_passthrough() {
155+
let base = make_temp_base();
156+
assert_eq!(
157+
validate_and_resolve("file::memory:?cache=shared", &base).unwrap(),
158+
PathBuf::from("file::memory:?cache=shared"),
159+
);
160+
}
161+
162+
#[test]
163+
fn test_mode_memory_passthrough() {
164+
let base = make_temp_base();
165+
assert_eq!(
166+
validate_and_resolve("file:test?mode=memory", &base).unwrap(),
167+
PathBuf::from("file:test?mode=memory"),
168+
);
169+
}
170+
171+
#[test]
172+
fn test_rejects_parent_traversal() {
173+
let base = make_temp_base();
174+
let err = validate_and_resolve("../../../etc/passwd", &base).unwrap_err();
175+
assert!(matches!(err, Error::PathTraversal(_)));
176+
}
177+
178+
#[test]
179+
fn test_rejects_absolute_path() {
180+
let base = make_temp_base();
181+
let err = validate_and_resolve("/etc/passwd", &base).unwrap_err();
182+
assert!(matches!(err, Error::PathTraversal(_)));
183+
}
184+
185+
#[test]
186+
fn test_rejects_embedded_traversal() {
187+
let base = make_temp_base();
188+
let err = validate_and_resolve("foo/../../bar", &base).unwrap_err();
189+
assert!(matches!(err, Error::PathTraversal(_)));
190+
}
191+
192+
#[test]
193+
fn test_rejects_null_byte() {
194+
let base = make_temp_base();
195+
let err = validate_and_resolve("path\0evil", &base).unwrap_err();
196+
assert!(matches!(err, Error::PathTraversal(_)));
197+
}
198+
199+
#[test]
200+
fn test_rejects_non_uri_mode_memory() {
201+
let base = make_temp_base();
202+
// A bare filename containing "mode=memory" is not a valid SQLite URI —
203+
// it should go through normal path validation, not be passed through.
204+
let result = validate_and_resolve("evil.db?mode=memory", &base).unwrap();
205+
assert_eq!(result, base.join("evil.db?mode=memory"));
206+
}
38207
}

0 commit comments

Comments
 (0)