Skip to content

Commit 693bf3d

Browse files
authored
Merge pull request #33 from kevinnft/fix/symlink-sandbox-escape
fix(security): reject writes through symlinked parent dirs in sandbox
2 parents db19e0b + bb32f4b commit 693bf3d

1 file changed

Lines changed: 66 additions & 1 deletion

File tree

src-tauri/src/tools/executor.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,31 @@ impl ToolExecutor {
147147
})?
148148
.to_path_buf();
149149
let normalized_rel = Self::normalize_relative(&rel_from_sandbox)?;
150-
Ok(sandbox_canonical.join(normalized_rel))
150+
let candidate = sandbox_canonical.join(&normalized_rel);
151+
152+
// Resolve the deepest existing ancestor through symlinks. This prevents an
153+
// attacker from creating a symlink inside the sandbox (e.g. evil -> /etc) and
154+
// then writing to evil/newfile, where the leaf does not exist but the parent
155+
// is a symlink that escapes the sandbox.
156+
let mut ancestor = candidate.as_path();
157+
loop {
158+
if ancestor.exists() {
159+
let canonical_ancestor = ancestor.canonicalize().map_err(AppError::from)?;
160+
if !canonical_ancestor.starts_with(&sandbox_canonical) {
161+
return Err(AppError::Validation(format!(
162+
"Path '{}' is outside project sandbox",
163+
requested
164+
)));
165+
}
166+
break;
167+
}
168+
match ancestor.parent() {
169+
Some(parent) => ancestor = parent,
170+
None => break,
171+
}
172+
}
173+
174+
Ok(candidate)
151175
}
152176

153177
fn is_sensitive_file(&self, path: &Path) -> bool {
@@ -603,6 +627,47 @@ mod tests {
603627
cleanup("path_traversal_abs");
604628
}
605629

630+
#[tokio::test]
631+
#[cfg(unix)]
632+
async fn test_symlink_parent_escape_rejected() {
633+
// Regression: an attacker creates a symlink inside the sandbox pointing to a
634+
// privileged directory (e.g. evil -> /tmp), then attempts to write a NEW file
635+
// through that symlink. The leaf does not exist, so the original validate_path
636+
// skipped canonicalization and tokio::fs::write happily followed the symlink,
637+
// letting the agent write outside the sandbox.
638+
let sandbox_path = with_sandbox("symlink_parent_escape");
639+
let outside = PathBuf::from("/tmp").join("enowx-test-symlink-target");
640+
tokio::fs::create_dir_all(&outside)
641+
.await
642+
.expect("create outside dir");
643+
644+
std::os::unix::fs::symlink(&outside, sandbox_path.join("evil"))
645+
.expect("create symlink");
646+
647+
let executor = ToolExecutor::new(sandbox_path);
648+
649+
let call = ToolCall {
650+
tool: ToolName::WriteFile,
651+
input: serde_json::json!({
652+
"path": "evil/pwned.txt",
653+
"content": "should not land outside sandbox",
654+
}),
655+
};
656+
let result = executor.execute(call).await;
657+
assert!(
658+
result.is_error,
659+
"write through symlinked parent must be rejected, got: {}",
660+
result.output
661+
);
662+
assert!(
663+
!outside.join("pwned.txt").exists(),
664+
"file must not have been written outside sandbox"
665+
);
666+
667+
let _ = tokio::fs::remove_dir_all(&outside).await;
668+
cleanup("symlink_parent_escape");
669+
}
670+
606671
#[tokio::test]
607672
async fn test_is_outside_sandbox() {
608673
let sandbox_path = with_sandbox("outside_sandbox");

0 commit comments

Comments
 (0)