fix(host-agent): SECURITY — file manager copy/list no longer follow symlinks out of the jail
Some checks failed
CI / backend-types (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
CI / agent-tests (push) Successful in 1m21s
Build Host Agent (Rust) / build (push) Successful in 1m34s
CI / integration (push) Has been cancelled

Automated security review (HIGH) caught a jail-escape my own review
missed: copy_recursive used fs::metadata (follows symlinks). A symlink
inside the jail pointing to e.g. /etc, then a 'copy' of its parent dir,
would dereference it and pull external content INTO the jail where it
could be read — a read-escape exfiltration. jail() validates only the
top-level src/dest; the recursive walk reintroduced the escape.

Fix: copy_recursive uses symlink_metadata and refuses any symlink
('symlinks are not followed across the jail boundary'). list() likewise
switched to symlink_metadata so it reports the link, never the
dereferenced target's size/type (info leak). Two regression tests added:
copy-symlink-exfil (asserts no external content lands inside) and
list-no-deref. 44/44 tests green. Rolled forward to alpha.4 (vulnerable
alpha.3 superseded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 11:57:08 -04:00
parent 7fdca2cd4f
commit 700dc2254d
4 changed files with 78 additions and 5 deletions

View File

@@ -198,7 +198,11 @@ pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
let mut entries: Vec<FileEntry> = Vec::new();
for item in rd {
let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
let meta = item.metadata().with_context(|| format!("stat '{}'", item.path().display()))?;
// symlink_metadata (lstat): report the link itself, never the target —
// following it would leak the size/type/existence of files outside the
// jail. A symlink lists as a zero-ish-size non-dir entry.
let meta = fs::symlink_metadata(item.path())
.with_context(|| format!("stat '{}'", item.path().display()))?;
let name = item.file_name().to_string_lossy().into_owned();
let is_dir = meta.is_dir();
@@ -367,11 +371,24 @@ pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
.with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}
/// Recursive copy helper (mirrors Go's `copyRecursive`).
/// Recursive copy helper.
///
/// SECURITY: uses `symlink_metadata` (does NOT follow symlinks) and refuses to
/// copy any symlink. `jail()` only validates the top-level src/dest; a symlink
/// *inside* a copied directory that points outside the jail would, if followed,
/// pull external content (e.g. `/etc`) into the jail where it could then be
/// read — a jail-escape exfiltration. Refusing symlinks closes that path.
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
let meta = fs::metadata(src)
let meta = fs::symlink_metadata(src)
.with_context(|| format!("stat source '{}'", src.display()))?;
if meta.file_type().is_symlink() {
bail!(
"refusing to copy symlink '{}' — symlinks are not followed across the jail boundary",
src.display()
);
}
if meta.is_dir() {
fs::create_dir_all(dest)
.with_context(|| format!("create_dir_all '{}'", dest.display()))?;