diff --git a/src/linker.rs b/src/linker.rs index e8855ba5..dc0b0286 100644 --- a/src/linker.rs +++ b/src/linker.rs @@ -388,6 +388,15 @@ impl Linker { // For NestedGlob, `source` is relative to the project root (not source_dir). let search_root = self.project_root.join(&target.source); + // SECURITY: Validate search root to prevent traversal/absolute escapes. + self.revalidate_destination_path(&search_root) + .with_context(|| { + format!( + "NestedGlob source resolves outside project root: {}", + target.source + ) + })?; + self.process_nested_glob( &search_root, target.pattern.as_deref().unwrap_or("**/AGENTS.md"), @@ -1150,6 +1159,10 @@ impl Linker { // Re-discover the same files and remove the corresponding symlinks. let search_root = self.project_root.join(&target_config.source); + // SECURITY: Validate search root to prevent traversal/absolute escapes. + if self.revalidate_destination_path(&search_root).is_err() { + continue; + } if !search_root.exists() || !search_root.is_dir() { continue; } diff --git a/tests/test_security.rs b/tests/test_security.rs new file mode 100644 index 00000000..27611341 --- /dev/null +++ b/tests/test_security.rs @@ -0,0 +1,94 @@ +use agentsync::config::Config; +use agentsync::linker::{Linker, SyncOptions}; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_nested_glob_search_root_traversal() { + let temp_dir = TempDir::new().unwrap(); + let project_root = temp_dir.path().join("project"); + let agents_dir = project_root.join(".agents"); + fs::create_dir_all(&agents_dir).unwrap(); + + // Create a file OUTSIDE the project root + let outside_dir = temp_dir.path().join("outside_dir"); + fs::create_dir_all(&outside_dir).unwrap(); + fs::write(outside_dir.join("AGENTS.md"), "outside").unwrap(); + + let config_path = agents_dir.join("agentsync.toml"); + + // We want to walk a directory OUTSIDE the project root + let relative_outside = "../outside_dir"; + + let toml = format!( + r#" + source_dir = "." + [agents.malicious] + enabled = true + [agents.malicious.targets.nested] + source = "{}" + destination = "leaked/{{file_name}}" + type = "nested-glob" + "#, + relative_outside + ); + + fs::write(&config_path, toml).unwrap(); + + let config = Config::load(&config_path).unwrap(); + let linker = Linker::new(config, config_path.clone()); + + let options = SyncOptions { + verbose: true, + ..Default::default() + }; + let result = linker.sync(&options).unwrap(); + + // The target should have failed due to unsafe search root + assert!( + result.errors > 0, + "Sync should have errors for malicious search root" + ); + + let leaked_link = project_root.join("leaked").join("AGENTS.md"); + assert!( + !leaked_link.exists(), + "Should NOT have created a symlink to a file discovered outside project root" + ); + assert!( + !project_root.join("leaked").exists(), + "Should NOT have created the leaked directory" + ); + + // Absolute paths should also be rejected. + // Use replace to escape backslashes so the path is valid in a TOML basic string on Windows. + let path_str = outside_dir.display().to_string().replace('\\', "\\\\"); + let absolute_toml = format!( + r#" + source_dir = "." + [agents.malicious] + enabled = true + [agents.malicious.targets.nested] + source = "{}" + destination = "leaked/{{file_name}}" + type = "nested-glob" + "#, + path_str + ); + fs::write(&config_path, absolute_toml).unwrap(); + + let absolute_config = Config::load(&config_path).unwrap(); + let absolute_linker = Linker::new(absolute_config, config_path); + let absolute_result = absolute_linker.sync(&options).unwrap(); + assert!( + absolute_result.errors > 0, + "Sync should have errors for absolute-path search root" + ); + + // clean() must not traverse or remove anything outside the project. + let clean_result = absolute_linker.clean(&SyncOptions::default()).unwrap(); + assert_eq!( + clean_result.removed, 0, + "Clean should not remove anything for an invalid search root" + ); +}