Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 40 additions & 1 deletion crates/soar-config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::{
use documented::{Documented, DocumentedFields};
use serde::{Deserialize, Serialize};
use soar_utils::{
path::{resolve_path, xdg_config_home, xdg_data_home},
path::{is_safe_component, resolve_path, xdg_config_home, xdg_data_home},
system::platform,
};
use toml_edit::DocumentMut;
Expand Down Expand Up @@ -428,6 +428,11 @@ impl Config {
let mut seen_repos = HashSet::new();

for repo in &mut self.repositories {
// The name becomes a directory under the repositories path, so it
// must not be able to escape it.
if !is_safe_component(&repo.name) {
return Err(ConfigError::InvalidRepositoryName(repo.name.clone()));
}
if repo.name == "local" {
return Err(ConfigError::ReservedRepositoryName);
}
Expand Down Expand Up @@ -674,6 +679,40 @@ mod tests {
assert!(matches!(result, Err(ConfigError::MissingDefaultProfile(_))));
}

fn repo_named(name: &str) -> Repository {
Repository {
name: name.to_string(),
url: "https://example.com".to_string(),
desktop_integration: None,
pubkey: None,
enabled: Some(true),
signature_verification: None,
sync_interval: None,
}
}

#[test]
fn test_config_resolve_rejects_traversal_repo_name() {
for name in ["..", "a/b", "/home/user/Documents", "", "."] {
let mut config = Config::default_config::<&str>(&[]);
config.repositories.push(repo_named(name));

let result = config.resolve();
assert!(
matches!(result, Err(ConfigError::InvalidRepositoryName(_))),
"name {name:?} should be rejected"
);
}
}

#[test]
fn test_config_resolve_accepts_normal_repo_name() {
let mut config = Config::default_config::<&str>(&[]);
config.repositories.push(repo_named("my-repo"));

assert!(config.resolve().is_ok());
}

#[test]
fn test_config_resolve_reserved_repo_name() {
let mut config = Config::default_config::<&str>(&[]);
Expand Down
21 changes: 18 additions & 3 deletions crates/soar-config/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,24 @@ pub enum ConfigError {
)]
MissingProfile(String),

#[error("Invalid repository name: {0}")]
#[diagnostic(code(soar_config::invalid_repository))]
InvalidRepository(String),
#[error("Repository '{0}' is not configured")]
#[diagnostic(
code(soar_config::repository_not_found),
help("Run `soar repo list` to see the configured repositories.")
)]
RepositoryNotFound(String),

#[error("Repository name '{0}' is not a valid directory name")]
#[diagnostic(
code(soar_config::invalid_repository_name),
help(
"A repository's data is stored in a directory named after it, under the \
repositories path, so the name must be a single path component. It cannot be \
empty, contain '/', be '.' or '..', or be an absolute path. Try a plain name, \
e.g. `soar repo add personal https://personal.repo`."
)
)]
InvalidRepositoryName(String),

#[error("Invalid repository URL: {0}")]
#[diagnostic(code(soar_config::invalid_repository_url))]
Expand Down
1 change: 1 addition & 0 deletions crates/soar-db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
soar-registry = { workspace = true }
soar-utils = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
28 changes: 1 addition & 27 deletions crates/soar-db/src/models/types.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
use std::path::{Component, Path};

use serde::{Deserialize, Serialize};

/// Returns `true` if `name` is a single, normal path component that is safe to
/// join onto a directory.
///
/// Rejects empty strings, absolute paths, `.`/`..`, and any value containing a
/// path separator. Used to keep untrusted `provides` metadata from escaping the
/// bin directory when it is turned into a symlink path.
pub fn is_safe_component(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
use soar_utils::path::is_safe_component;

#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum ProvideStrategy {
Expand Down Expand Up @@ -137,20 +125,6 @@ mod tests {
assert_eq!(p.bin_symlink_names(), vec!["clipcat"]);
}

#[test]
fn test_is_safe_component() {
assert!(is_safe_component("clipcat"));
assert!(is_safe_component("clip-cat.1"));

assert!(!is_safe_component(""));
assert!(!is_safe_component("."));
assert!(!is_safe_component(".."));
assert!(!is_safe_component("a/b"));
assert!(!is_safe_component("../etc"));
assert!(!is_safe_component("../../home/user/.bashrc"));
assert!(!is_safe_component("/etc/passwd"));
}

#[test]
fn test_provide_is_safe() {
assert!(PackageProvide::from_string("clipcatd").is_safe());
Expand Down
35 changes: 31 additions & 4 deletions crates/soar-operations/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl SoarContext {
.iter_mut()
.find(|r| r.name == name)
.ok_or_else(|| {
soar_config::error::ConfigError::InvalidRepository(name.to_string())
soar_config::error::ConfigError::RepositoryNotFound(name.to_string())
})?;

if let Some(url) = update.url {
Expand Down Expand Up @@ -89,17 +89,44 @@ impl SoarContext {
.iter()
.position(|r| r.name == name)
.ok_or_else(|| {
soar_config::error::ConfigError::InvalidRepository(name.to_string())
soar_config::error::ConfigError::RepositoryNotFound(name.to_string())
})?;

let repo = config.repositories.remove(idx);

// Clean up the repository's data directory
if let Ok(repo_path) = repo.get_path() {
if repo_path.exists() {
fs::remove_dir_all(&repo_path).with_context(|| {
format!("removing repository data at {}", repo_path.display())
// Canonicalize before deleting: `Path::parent` is lexical, so
// a name like ".." would pass a parent check while still
// resolving outside the repositories directory.
let repos_dir = config.get_repositories_path()?;
let repos_dir = repos_dir.canonicalize().with_context(|| {
format!("canonicalizing repositories dir {}", repos_dir.display())
})?;
let target = repo_path.canonicalize().with_context(|| {
format!("canonicalizing repository path {}", repo_path.display())
})?;
if target == repos_dir || !target.starts_with(&repos_dir) {
return Err(soar_config::error::ConfigError::InvalidRepositoryName(
repo.name.clone(),
)
.into());
}
// Remove the configured path rather than the canonicalized
// target: when the repo dir is a symlink, drop the link
// itself instead of the directory it points at.
let meta = fs::symlink_metadata(&repo_path)
.with_context(|| format!("reading metadata for {}", repo_path.display()))?;
if meta.file_type().is_symlink() {
fs::remove_file(&repo_path).with_context(|| {
format!("removing repository symlink at {}", repo_path.display())
})?;
} else {
fs::remove_dir_all(&repo_path).with_context(|| {
format!("removing repository data at {}", repo_path.display())
})?;
}
}
}

Expand Down
41 changes: 40 additions & 1 deletion crates/soar-utils/src/path.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
use std::{env, path::PathBuf};
use std::{
env,
path::{Component, Path, PathBuf},
};

use crate::{
error::{PathError, PathResult},
system::get_username,
};

/// Returns `true` if `name` is a single, normal path component that is safe to
/// join onto a directory.
///
/// Rejects empty strings, absolute paths, `.`/`..`, and any value containing a
/// path separator. Use this for untrusted values that become a path component,
/// such as repository names or package `provides` entries, where a join must
/// not escape its base directory.
///
/// # Example
///
/// ```
/// use soar_utils::path::is_safe_component;
///
/// assert!(is_safe_component("soarpkgs"));
/// assert!(!is_safe_component("../etc"));
/// ```
pub fn is_safe_component(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}

/// Resolves a path string that may contain environment variables
///
/// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde
Expand Down Expand Up @@ -236,6 +260,21 @@ fn expand_env_var(var_name: &str, result: &mut String, original: &str) -> PathRe

#[cfg(test)]
mod tests {
#[test]
fn test_is_safe_component() {
assert!(super::is_safe_component("clipcat"));
assert!(super::is_safe_component("clip-cat.1"));
assert!(super::is_safe_component("soarpkgs"));

assert!(!super::is_safe_component(""));
assert!(!super::is_safe_component("."));
assert!(!super::is_safe_component(".."));
assert!(!super::is_safe_component("a/b"));
assert!(!super::is_safe_component("../etc"));
assert!(!super::is_safe_component("../../home/user/.bashrc"));
assert!(!super::is_safe_component("/etc/passwd"));
}

use std::env;

use serial_test::serial;
Expand Down
Loading