Skip to content

Commit bb32f4b

Browse files
committed
fix(security): reject writes through symlinked parent dirs in sandbox
ToolExecutor::validate_path correctly canonicalized requests when the target file already existed, but the non-existent-path branch only ran normalize_relative on the requested path components and then joined them onto the canonicalized sandbox without resolving symlinks. An agent able to create a file inside the sandbox could plant a symlink to anywhere on disk and then write through it: run_command ln -s /etc evil write_file { path: "evil/passwd", content: ... } The leaf "evil/passwd" did not exist, so canonicalization was skipped and tokio::fs::write followed the symlink, writing arbitrary content outside the sandbox. The fix walks up the candidate path to the deepest existing ancestor, canonicalizes that ancestor (which resolves any symlink in the chain), and rejects the request if the resolved ancestor falls outside the sandbox. The leaf path is still returned as-is so write_file's create_dir_all behaviour is preserved. A regression test plants a symlink and attempts the write — it now fails with a sandbox-escape error.
1 parent 8ff8d61 commit bb32f4b

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
@@ -127,7 +127,31 @@ impl ToolExecutor {
127127
})?
128128
.to_path_buf();
129129
let normalized_rel = Self::normalize_relative(&rel_from_sandbox)?;
130-
Ok(sandbox_canonical.join(normalized_rel))
130+
let candidate = sandbox_canonical.join(&normalized_rel);
131+
132+
// Resolve the deepest existing ancestor through symlinks. This prevents an
133+
// attacker from creating a symlink inside the sandbox (e.g. evil -> /etc) and
134+
// then writing to evil/newfile, where the leaf does not exist but the parent
135+
// is a symlink that escapes the sandbox.
136+
let mut ancestor = candidate.as_path();
137+
loop {
138+
if ancestor.exists() {
139+
let canonical_ancestor = ancestor.canonicalize().map_err(AppError::from)?;
140+
if !canonical_ancestor.starts_with(&sandbox_canonical) {
141+
return Err(AppError::Validation(format!(
142+
"Path '{}' is outside project sandbox",
143+
requested
144+
)));
145+
}
146+
break;
147+
}
148+
match ancestor.parent() {
149+
Some(parent) => ancestor = parent,
150+
None => break,
151+
}
152+
}
153+
154+
Ok(candidate)
131155
}
132156

133157
fn is_sensitive_file(&self, path: &Path) -> bool {
@@ -435,6 +459,47 @@ mod tests {
435459
cleanup("path_traversal_abs");
436460
}
437461

462+
#[tokio::test]
463+
#[cfg(unix)]
464+
async fn test_symlink_parent_escape_rejected() {
465+
// Regression: an attacker creates a symlink inside the sandbox pointing to a
466+
// privileged directory (e.g. evil -> /tmp), then attempts to write a NEW file
467+
// through that symlink. The leaf does not exist, so the original validate_path
468+
// skipped canonicalization and tokio::fs::write happily followed the symlink,
469+
// letting the agent write outside the sandbox.
470+
let sandbox_path = with_sandbox("symlink_parent_escape");
471+
let outside = PathBuf::from("/tmp").join("enowx-test-symlink-target");
472+
tokio::fs::create_dir_all(&outside)
473+
.await
474+
.expect("create outside dir");
475+
476+
std::os::unix::fs::symlink(&outside, sandbox_path.join("evil"))
477+
.expect("create symlink");
478+
479+
let executor = ToolExecutor::new(sandbox_path);
480+
481+
let call = ToolCall {
482+
tool: ToolName::WriteFile,
483+
input: serde_json::json!({
484+
"path": "evil/pwned.txt",
485+
"content": "should not land outside sandbox",
486+
}),
487+
};
488+
let result = executor.execute(call).await;
489+
assert!(
490+
result.is_error,
491+
"write through symlinked parent must be rejected, got: {}",
492+
result.output
493+
);
494+
assert!(
495+
!outside.join("pwned.txt").exists(),
496+
"file must not have been written outside sandbox"
497+
);
498+
499+
let _ = tokio::fs::remove_dir_all(&outside).await;
500+
cleanup("symlink_parent_escape");
501+
}
502+
438503
#[tokio::test]
439504
async fn test_is_outside_sandbox() {
440505
let sandbox_path = with_sandbox("outside_sandbox");

0 commit comments

Comments
 (0)