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
13 changes: 13 additions & 0 deletions src/linker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;
}
Expand Down
94 changes: 94 additions & 0 deletions tests/test_security.rs
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
);
}
Loading