Skip to content

Commit b68a7b2

Browse files
authored
fix(fs): handle symlinks in overlay rename and copy (#1014)
## Summary - `OverlayFs::rename` and `copy` used `read_file()` which intentionally doesn't follow symlinks (TM-ESC-002), causing silent failures when operating on symlinks. Now detects symlinks via `stat()` and preserves them using `read_link()` + `symlink()`. - Same fix applied to `MountableFs` cross-mount rename/copy paths. - Added TM-ESC-016 to threat model spec and public docs. ## Test plan - [x] `overlay_rename_preserves_symlink` — rename symlink in upper layer - [x] `overlay_rename_symlink_from_lower_layer` — rename symlink from lower to upper (CoW) - [x] `overlay_copy_preserves_symlink` — copy preserves symlink type - [x] `symlink_rename_cannot_escape_mount_via_read` — read_file on renamed symlink still blocked - [x] `bash_mv_symlink_in_overlay` — end-to-end mv + readlink in bash session - [x] Full test suite passes, clippy clean
1 parent d99f228 commit b68a7b2

File tree

5 files changed

+282
-15
lines changed

5 files changed

+282
-15
lines changed

crates/bashkit/docs/threat-model.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Scripts may attempt to break out of the sandbox to access the host system.
152152
| VFS limit bypass (TM-ESC-012) | `add_file()` skips limits | Restrict API visibility | **OPEN** |
153153
| OverlayFs upper() exposed (TM-ESC-013) | `upper()` returns unlimited FS | Restrict visibility | **OPEN** |
154154
| Custom builtins lost (TM-ESC-014) | `std::mem::take` empties builtins | Arc-cloned builtins | **FIXED** |
155+
| Symlink overlay rename (TM-ESC-016) | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks | **FIXED** |
155156

156157
**Process Escape:**
157158

crates/bashkit/src/fs/mountable.rs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -429,10 +429,18 @@ impl FileSystem for MountableFs {
429429
if Arc::ptr_eq(&from_fs, &to_fs) {
430430
from_fs.rename(&from_resolved, &to_resolved).await
431431
} else {
432-
// Cross-mount rename: copy then delete
433-
let content = from_fs.read_file(&from_resolved).await?;
434-
to_fs.write_file(&to_resolved, &content).await?;
435-
from_fs.remove(&from_resolved, false).await
432+
// Cross-mount rename: handle symlinks specially since read_file
433+
// intentionally doesn't follow them (THREAT[TM-ESC-002]).
434+
let meta = from_fs.stat(&from_resolved).await?;
435+
if meta.file_type == FileType::Symlink {
436+
let target = from_fs.read_link(&from_resolved).await?;
437+
to_fs.symlink(&target, &to_resolved).await?;
438+
from_fs.remove(&from_resolved, false).await
439+
} else {
440+
let content = from_fs.read_file(&from_resolved).await?;
441+
to_fs.write_file(&to_resolved, &content).await?;
442+
from_fs.remove(&from_resolved, false).await
443+
}
436444
}
437445
}
438446

@@ -445,9 +453,15 @@ impl FileSystem for MountableFs {
445453
if Arc::ptr_eq(&from_fs, &to_fs) {
446454
from_fs.copy(&from_resolved, &to_resolved).await
447455
} else {
448-
// Cross-mount copy
449-
let content = from_fs.read_file(&from_resolved).await?;
450-
to_fs.write_file(&to_resolved, &content).await
456+
// Cross-mount copy: handle symlinks specially (THREAT[TM-ESC-002]).
457+
let meta = from_fs.stat(&from_resolved).await?;
458+
if meta.file_type == FileType::Symlink {
459+
let target = from_fs.read_link(&from_resolved).await?;
460+
to_fs.symlink(&target, &to_resolved).await
461+
} else {
462+
let content = from_fs.read_file(&from_resolved).await?;
463+
to_fs.write_file(&to_resolved, &content).await
464+
}
451465
}
452466
}
453467

crates/bashkit/src/fs/overlay.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -679,13 +679,22 @@ impl FileSystem for OverlayFs {
679679
let from = Self::normalize_path(from);
680680
let to = Self::normalize_path(to);
681681

682-
// Read from source (checking both layers)
683-
let content = self.read_file(&from).await?;
682+
// THREAT[TM-ESC-002]: Check if source is a symlink first.
683+
// Symlinks must be moved as symlinks, not dereferenced via read_file
684+
// (which would fail since InMemoryFs intentionally doesn't follow them).
685+
let meta = self.stat(&from).await?;
686+
if meta.file_type == FileType::Symlink {
687+
let target = self.read_link(&from).await?;
688+
self.check_write_limits(0)?;
689+
self.remove_whiteout(&to);
690+
self.upper.symlink(&target, &to).await?;
691+
self.remove(&from, false).await?;
692+
return Ok(());
693+
}
684694

685-
// Write to destination in upper
695+
// Regular file: read content and write to new location
696+
let content = self.read_file(&from).await?;
686697
self.write_file(&to, &content).await?;
687-
688-
// Delete source (will add whiteout if needed)
689698
self.remove(&from, false).await?;
690699

691700
Ok(())
@@ -702,10 +711,17 @@ impl FileSystem for OverlayFs {
702711
let from = Self::normalize_path(from);
703712
let to = Self::normalize_path(to);
704713

705-
// Read from source (checking both layers)
706-
let content = self.read_file(&from).await?;
714+
// THREAT[TM-ESC-002]: Copy symlinks as symlinks, not via read_file.
715+
let meta = self.stat(&from).await?;
716+
if meta.file_type == FileType::Symlink {
717+
let target = self.read_link(&from).await?;
718+
self.check_write_limits(0)?;
719+
self.remove_whiteout(&to);
720+
return self.upper.symlink(&target, &to).await;
721+
}
707722

708-
// Write to destination in upper
723+
// Regular file
724+
let content = self.read_file(&from).await?;
709725
self.write_file(&to, &content).await
710726
}
711727

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
//! Security tests for symlink handling in overlay mode.
2+
//!
3+
//! THREAT[TM-ESC-002]: Validates that symlinks cannot be used to escape
4+
//! mount boundaries, especially after rename/move operations.
5+
6+
use bashkit::{Bash, FileSystem, InMemoryFs, MountableFs, OverlayFs};
7+
use std::path::Path;
8+
use std::sync::Arc;
9+
10+
/// Renaming a symlink in overlay mode must preserve it as a symlink
11+
/// (not silently fail or convert to a regular file).
12+
#[tokio::test]
13+
async fn overlay_rename_preserves_symlink() {
14+
let lower = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
15+
let overlay = Arc::new(OverlayFs::new(lower));
16+
17+
// Create a file and a symlink to it
18+
overlay
19+
.write_file(Path::new("/target.txt"), b"hello")
20+
.await
21+
.unwrap();
22+
overlay
23+
.symlink(Path::new("/target.txt"), Path::new("/link1"))
24+
.await
25+
.unwrap();
26+
27+
// Rename the symlink
28+
overlay
29+
.rename(Path::new("/link1"), Path::new("/link2"))
30+
.await
31+
.unwrap();
32+
33+
// The renamed entry should still be a symlink
34+
let target = overlay.read_link(Path::new("/link2")).await.unwrap();
35+
assert_eq!(target, Path::new("/target.txt"));
36+
37+
// Original should be gone
38+
assert!(!overlay.exists(Path::new("/link1")).await.unwrap());
39+
}
40+
41+
/// Renaming a symlink from the lower layer into the upper layer must
42+
/// preserve it as a symlink in the upper layer.
43+
#[tokio::test]
44+
async fn overlay_rename_symlink_from_lower_layer() {
45+
let lower = Arc::new(InMemoryFs::new());
46+
lower
47+
.write_file(Path::new("/data.txt"), b"secret")
48+
.await
49+
.unwrap();
50+
lower
51+
.symlink(Path::new("/data.txt"), Path::new("/link_lower"))
52+
.await
53+
.unwrap();
54+
55+
let overlay = Arc::new(OverlayFs::new(lower.clone() as Arc<dyn FileSystem>));
56+
57+
// Rename symlink from lower to a new name (goes to upper)
58+
overlay
59+
.rename(Path::new("/link_lower"), Path::new("/link_upper"))
60+
.await
61+
.unwrap();
62+
63+
// Should be a symlink in the overlay
64+
let target = overlay.read_link(Path::new("/link_upper")).await.unwrap();
65+
assert_eq!(target, Path::new("/data.txt"));
66+
}
67+
68+
/// Copying a symlink in overlay mode should copy it as a symlink.
69+
#[tokio::test]
70+
async fn overlay_copy_preserves_symlink() {
71+
let lower = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
72+
let overlay = Arc::new(OverlayFs::new(lower));
73+
74+
overlay
75+
.write_file(Path::new("/target.txt"), b"data")
76+
.await
77+
.unwrap();
78+
overlay
79+
.symlink(Path::new("/target.txt"), Path::new("/link"))
80+
.await
81+
.unwrap();
82+
83+
overlay
84+
.copy(Path::new("/link"), Path::new("/link_copy"))
85+
.await
86+
.unwrap();
87+
88+
// Copy should be a symlink with same target
89+
let target = overlay.read_link(Path::new("/link_copy")).await.unwrap();
90+
assert_eq!(target, Path::new("/target.txt"));
91+
92+
// Original still exists
93+
let orig_target = overlay.read_link(Path::new("/link")).await.unwrap();
94+
assert_eq!(orig_target, Path::new("/target.txt"));
95+
}
96+
97+
/// A symlink pointing outside a mount should not allow reading the external
98+
/// file through cat after being moved within the mount.
99+
#[tokio::test]
100+
async fn symlink_rename_cannot_escape_mount_via_read() {
101+
// Lower layer has /etc/passwd (simulating files outside the sandbox scope)
102+
let lower = Arc::new(InMemoryFs::new());
103+
lower.mkdir(Path::new("/etc"), false).await.unwrap();
104+
lower
105+
.write_file(Path::new("/etc/passwd"), b"root:x:0:0")
106+
.await
107+
.unwrap();
108+
lower.mkdir(Path::new("/sandbox"), false).await.unwrap();
109+
110+
let overlay = Arc::new(OverlayFs::new(lower as Arc<dyn FileSystem>));
111+
112+
// Create a symlink inside the overlay pointing to /etc/passwd
113+
overlay
114+
.symlink(Path::new("/etc/passwd"), Path::new("/sandbox/evil"))
115+
.await
116+
.unwrap();
117+
118+
// Rename symlink within the sandbox
119+
overlay
120+
.rename(Path::new("/sandbox/evil"), Path::new("/sandbox/moved"))
121+
.await
122+
.unwrap();
123+
124+
// The renamed symlink should exist and point to /etc/passwd
125+
let target = overlay
126+
.read_link(Path::new("/sandbox/moved"))
127+
.await
128+
.unwrap();
129+
assert_eq!(target, Path::new("/etc/passwd"));
130+
131+
// But reading through read_file must NOT follow the symlink and return content
132+
// (symlinks are intentionally not followed — TM-ESC-002)
133+
let result = overlay.read_file(Path::new("/sandbox/moved")).await;
134+
assert!(result.is_err(), "read_file on symlink must not follow it");
135+
}
136+
137+
/// Cross-mount rename of a symlink in MountableFs must preserve the symlink.
138+
#[tokio::test]
139+
async fn mountable_cross_mount_rename_preserves_symlink() {
140+
let root = Arc::new(InMemoryFs::new());
141+
let mount_a = Arc::new(InMemoryFs::new());
142+
let mount_b = Arc::new(InMemoryFs::new());
143+
144+
// Create a symlink in mount_a
145+
mount_a
146+
.symlink(Path::new("/target.txt"), Path::new("/link"))
147+
.await
148+
.unwrap();
149+
150+
let mountable = MountableFs::new(root as Arc<dyn FileSystem>);
151+
mountable
152+
.mount("/mnt/a", mount_a as Arc<dyn FileSystem>)
153+
.unwrap();
154+
mountable
155+
.mount("/mnt/b", mount_b as Arc<dyn FileSystem>)
156+
.unwrap();
157+
158+
// Cross-mount rename: /mnt/a/link -> /mnt/b/link
159+
mountable
160+
.rename(Path::new("/mnt/a/link"), Path::new("/mnt/b/link"))
161+
.await
162+
.unwrap();
163+
164+
// Should be a symlink in mount_b
165+
let target = mountable.read_link(Path::new("/mnt/b/link")).await.unwrap();
166+
assert_eq!(target, Path::new("/target.txt"));
167+
168+
// Source should be gone
169+
assert!(!mountable.exists(Path::new("/mnt/a/link")).await.unwrap());
170+
}
171+
172+
/// Cross-mount copy of a symlink in MountableFs must preserve the symlink.
173+
#[tokio::test]
174+
async fn mountable_cross_mount_copy_preserves_symlink() {
175+
let root = Arc::new(InMemoryFs::new());
176+
let mount_a = Arc::new(InMemoryFs::new());
177+
let mount_b = Arc::new(InMemoryFs::new());
178+
179+
mount_a
180+
.symlink(Path::new("/target.txt"), Path::new("/link"))
181+
.await
182+
.unwrap();
183+
184+
let mountable = MountableFs::new(root as Arc<dyn FileSystem>);
185+
mountable
186+
.mount("/mnt/a", mount_a as Arc<dyn FileSystem>)
187+
.unwrap();
188+
mountable
189+
.mount("/mnt/b", mount_b as Arc<dyn FileSystem>)
190+
.unwrap();
191+
192+
// Cross-mount copy
193+
mountable
194+
.copy(Path::new("/mnt/a/link"), Path::new("/mnt/b/link"))
195+
.await
196+
.unwrap();
197+
198+
// Both should be symlinks
199+
let target_a = mountable.read_link(Path::new("/mnt/a/link")).await.unwrap();
200+
let target_b = mountable.read_link(Path::new("/mnt/b/link")).await.unwrap();
201+
assert_eq!(target_a, Path::new("/target.txt"));
202+
assert_eq!(target_b, Path::new("/target.txt"));
203+
}
204+
205+
/// mv of a symlink in a bash session should work and preserve the symlink.
206+
#[tokio::test]
207+
async fn bash_mv_symlink_in_overlay() {
208+
let lower = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
209+
let overlay = Arc::new(OverlayFs::new(lower));
210+
211+
overlay
212+
.write_file(Path::new("/tmp/target.txt"), b"content")
213+
.await
214+
.unwrap();
215+
overlay.mkdir(Path::new("/tmp"), true).await.unwrap_or(()); // may already exist
216+
217+
let mut bash = Bash::builder().fs(overlay.clone()).build();
218+
219+
bash.exec("ln -s /tmp/target.txt /tmp/mylink")
220+
.await
221+
.unwrap();
222+
let result = bash.exec("mv /tmp/mylink /tmp/renamed_link").await.unwrap();
223+
assert_eq!(result.exit_code, 0, "mv should succeed: {}", result.stderr);
224+
225+
// readlink should show the original target
226+
let result = bash.exec("readlink /tmp/renamed_link").await.unwrap();
227+
assert_eq!(result.stdout.trim(), "/tmp/target.txt");
228+
}

specs/006-threat-model.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ max_parser_operations: 100_000, // Parser fuel (TM-DOS-024)
320320
| TM-ESC-002 | Symlink escape | `ln -s /etc/passwd /tmp/x` | Symlinks not followed | **MITIGATED** |
321321
| TM-ESC-003 | Real FS access | Direct syscalls | No real FS by default | **MITIGATED** |
322322
| TM-ESC-004 | Mount escape | Mount real paths | MountableFs controlled | **MITIGATED** |
323+
| TM-ESC-016 | Symlink escape via overlay rename | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks as symlinks | **FIXED** |
323324

324325
**Current Risk**: MEDIUM - Two open escape vectors (TM-ESC-012, TM-ESC-013) need remediation
325326

@@ -342,6 +343,13 @@ or return a view that enforces the overlay's limits.
342343
**TM-ESC-014**: Fixed. `BashTool::create_bash()` now clones `Arc`-wrapped builtins instead of
343344
taking ownership via `std::mem::take`. Custom builtins persist across multiple calls. See `tool.rs:659-662`.
344345

346+
**TM-ESC-016**: Fixed. `OverlayFs::rename` and `copy` previously used `read_file()` + `write_file()`
347+
which silently failed on symlinks (since `read_file` intentionally doesn't follow symlinks per
348+
TM-ESC-002). A symlink could not be renamed at all, but the underlying design gap meant any future
349+
relaxation of symlink-following policy could expose the target. Fix: `rename`/`copy` now detect
350+
symlinks via `stat()` and use `read_link()` + `symlink()` to preserve them. Same fix applied to
351+
`MountableFs` cross-mount operations. See `fs/overlay.rs` and `fs/mountable.rs`.
352+
345353
#### 2.2 Process Escape
346354

347355
| ID | Threat | Attack Vector | Mitigation | Status |

0 commit comments

Comments
 (0)