diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 986d7ff..38a9f76 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -7,3 +7,8 @@ **Vulnerability:** The `maybe_write_text` utility function was using `std::fs::write`, which resulted in sensitive data (like PSBT files and offers) being saved with insecure default file permissions, making them readable by other users on a shared system. **Learning:** Even generic utility functions used for saving user-requested command outputs must use secure file permissions (`0o600`) if the data they handle (like PSBTs and offers) is sensitive. **Prevention:** Always use `crate::paths::write_secure_file` instead of `std::fs::write` for all file writing operations that might contain sensitive material in this codebase. + +## 2024-05-31 - [Path Traversal in Snapshot Command] +**Vulnerability:** Path traversal in user-provided input `name` parameter in `SnapshotAction::Save` and `SnapshotAction::Restore` allowed users to provide file paths pointing outside the snapshot directory (e.g. `../../etc/passwd`). Also identified insecure creation of directory due to standard `fs::create_dir_all`. +**Learning:** Raw user input was joined with directories to access files, potentially allowing an attacker to read/write arbitrary files, especially when not validating special characters like `..` or `/`. In addition, creating sensitive directories without proper permissions opens up to security risks. +**Prevention:** Implement strict file name allowlist validation to limit acceptable characters to alphanumerics, dashes, and underscores (e.g. using a helper like `validate_file_name`) before utilizing the input in file system operations. Also, prefer `create_secure_dir_all` over `fs::create_dir_all` to respect correct permission boundaries. diff --git a/src/commands/snapshot.rs b/src/commands/snapshot.rs index 7be1d9e..7c4f462 100644 --- a/src/commands/snapshot.rs +++ b/src/commands/snapshot.rs @@ -7,11 +7,12 @@ use std::fs; pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { let profile_path = profile_path(cli)?; let snap_dir = snapshot_dir(cli)?; - fs::create_dir_all(&snap_dir) + crate::paths::create_secure_dir_all(&snap_dir) .map_err(|e| AppError::Config(format!("failed to create snapshot dir: {e}")))?; match &args.action { SnapshotAction::Save { name, overwrite } => { + crate::utils::validate_file_name(name)?; let source = read_profile(&profile_path)?; let destination = snap_dir.join(format!("{name}.json")); if destination.exists() && !(*overwrite || cli.yes) { @@ -27,6 +28,7 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { + crate::utils::validate_file_name(name)?; if !confirm(&format!("Are you sure you want to restore snapshot '{name}'? This will overwrite your current profile."), cli) { return Err(AppError::Internal("aborted by user".to_string())); } diff --git a/src/utils.rs b/src/utils.rs index 8e3996f..793a511 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -219,3 +219,18 @@ pub fn parse_indices(s: Option<&str>) -> Result, AppError> { } Ok(indices) } + +pub fn validate_file_name(name: &str) -> Result<(), AppError> { + if name.is_empty() { + return Err(AppError::Invalid("file name cannot be empty".to_string())); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(AppError::Invalid( + "file name contains invalid characters".to_string(), + )); + } + Ok(()) +}