From 442cd8a2e22fc26c6dffdcfc56a8d366a98ad78e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 6 Aug 2026 23:59:09 -0500 Subject: [PATCH] fix(fs): prevent host mount path traversal --- crates/bashkit/docs/threat-model.md | 1 + crates/bashkit/src/lib.rs | 11 ++++++- .../tests/integration/host_mounts_tests.rs | 31 +++++++++++++++++++ knowledge/foundations/vfs.md | 5 ++- knowledge/security/threat-model.md | 7 +++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 25e0463f1..b601c9bb0 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -181,6 +181,7 @@ Scripts may attempt to break out of the sandbox to access the host system. | Symlink overlay rename (TM-ESC-016) | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks | **FIXED** | | Namespace source-root or policy escape (TM-ESC-031) | `..` escapes a rebased or nested mount | Normalize before longest-prefix selection; join only the stripped suffix; enforce both mutation endpoints | MITIGATED | | Windows host-path namespace escape (TM-ESC-033) | Drive/UNC/device path or reparse point discards the RealFS root | Normalize into the POSIX VFS root; canonicalize existing ancestors; component-aware root check; Windows CI | MITIGATED | +| Host mount resolver traversal (TM-ESC-034) | `/workspace/../secret` passed to `host_path_for` keeps an unnormalized suffix that escapes the selected host mount when joined | Normalize mount points and lookup paths with the shared POSIX VFS normalizer before longest-prefix selection and host joining | MITIGATED | **Process Escape:** diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index d79c6df27..5f21145a6 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1729,7 +1729,13 @@ impl HostMounts { /// mounted. pub fn new(mounts: impl IntoIterator) -> Self { Self { - mounts: mounts.into_iter().collect(), + mounts: mounts + .into_iter() + .map(|mut mount| { + mount.vfs_path = normalize_path(&mount.vfs_path); + mount + }) + .collect(), } } @@ -1760,6 +1766,9 @@ impl HostMounts { if !vfs_path.has_root() { return None; } + // Match the VFS meaning of the path, not its spelling. Otherwise the + // stripped suffix can retain `..` and escape when the host joins it. + let vfs_path = normalize_path(vfs_path); self.mounts .iter() .filter_map(|mount| { diff --git a/crates/bashkit/tests/integration/host_mounts_tests.rs b/crates/bashkit/tests/integration/host_mounts_tests.rs index 0186d7497..57048c82a 100644 --- a/crates/bashkit/tests/integration/host_mounts_tests.rs +++ b/crates/bashkit/tests/integration/host_mounts_tests.rs @@ -84,6 +84,37 @@ fn relative_paths_do_not_resolve() { assert_eq!(mounts().resolve(Path::new("../escape")), None); } +#[test] +fn parent_components_are_normalized_before_mount_selection() { + assert_eq!( + mounts().resolve(Path::new("/workspace/../secrets/flag.txt")), + Some(PathBuf::from("/srv/root/secrets/flag.txt")) + ); + + let only_workspace = HostMounts::new([HostMount { + host_path: PathBuf::from("/home/user/proj"), + vfs_path: PathBuf::from("/workspace"), + }]); + assert_eq!( + only_workspace.resolve(Path::new("/workspace/../secrets/flag.txt")), + None + ); +} + +#[test] +fn mount_points_are_normalized_when_the_table_is_built() { + let normalized = HostMounts::new([HostMount { + host_path: PathBuf::from("/home/user/proj"), + vfs_path: PathBuf::from("/staging/../workspace"), + }]); + + assert_eq!(normalized.all()[0].vfs_path, Path::new("/workspace")); + assert_eq!( + normalized.resolve(Path::new("/workspace/src")), + Some(PathBuf::from("/home/user/proj/src")) + ); +} + #[test] fn unmapped_path_is_none_when_nothing_is_mounted() { let empty = HostMounts::default(); diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 07bcde8e2..c50fb55d4 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -217,7 +217,10 @@ Symlinks are stored but intentionally not followed for security: `realfs` mounts are recorded as a `HostMounts` table on the `Bash` instance: `Bash::host_mounts()` lists them, `Bash::host_path_for(vfs)` maps a VFS path -back to the host path backing it. +back to the host path backing it. Mount points and lookup paths use the shared +POSIX VFS normalizer before longest-prefix selection and host joining. Thus +`.`/`..` cannot select a mount under their unnormalized spelling or survive in +the suffix passed to the host OS (TM-ESC-034). Decision: published because embedders that bridge commands to host processes must map a VFS cwd to a host directory to spawn in, and hand-rolling it is a diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index a210272be..0972308c9 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -317,6 +317,7 @@ panicked. Resolved with `wrapping_*` ops, masked shift amounts, clamped exponent | TM-ESC-016 | Symlink escape via overlay rename | `ln -s /etc/passwd x; mv x y` | Overlay rename/copy preserve symlinks as symlinks | **FIXED** | | TM-ESC-031 | Namespace source-root or policy escape | `..` selects a shorter mount, escapes a rebased source root, or bypasses a nested read-only mount | Normalize before longest-prefix selection; join only the stripped suffix; independently enforce both mutation endpoints | **MITIGATED** | | TM-ESC-033 | Windows host-path namespace escape | A direct VFS/RealFs path preserves a drive-relative, drive-absolute, UNC, or device prefix; `root.join(path)` discards the configured root, or a symlink/junction redirects an existing prefix | Shared POSIX VFS normalization discards host prefixes before backend joins; RealFs canonicalizes existing paths or the nearest existing ancestor and performs component-aware root checks; drive-relative symlink targets are rejected; Windows CI exercises alternate separators, case behavior, root-prefix siblings, reparse points, and missing descendants | **MITIGATED** | +| TM-ESC-034 | Host mount resolver traversal | An embedder passes `/workspace/../secret` to `host_path_for`, and an unnormalized suffix escapes the selected host mount when joined | Normalize mount points and lookup paths with the shared POSIX VFS normalizer before longest-prefix selection and host joining | **MITIGATED** | | TM-FS-013 | Permissive RealFs mount default | `mount_real_readonly_at("/", …)` exposes whole host without `allowed_mount_paths` | Allowlist-first: `/`, `/etc`, `/root`, `/Users`, `/home`, `/dev`, `/proc`, `/sys`, `/run`, `/var/run`, `/boot`, `/private`, and any path component matching `.ssh`, `.aws`, `.kube`, `.docker`, `.gnupg`, `.gcloud` are refused unless explicitly allowlisted | **MITIGATED** | | TM-FS-014 | Partial filesystem mutation | Failed write/copy or copy-delete move corrupts/replaces a destination, duplicates a source, or consumes retained quota | `FileSystem` failure-atomicity contract; locked in-memory rename; MountableFs restores cross-mount destinations while NamespaceFs rejects cross-mount rename; RealFs stages and flushes sibling files before rename; failpoint and conformance regressions | **MITIGATED** | | TM-FS-015 | Partial archive extraction | A late traversal, malformed header, or size failure leaves earlier attacker-controlled files behind | Tar validates the complete archive and per-file limits before its first VFS mutation; conformance regression uses a valid entry followed by traversal | **MITIGATED** | @@ -362,6 +363,12 @@ canonicalized, so symlink/junction targets, root-prefix siblings, and missing suffixes cannot escape. `windows_containment_*` tests run continuously on a Windows runner; drive roots are also sensitive mounts under TM-FS-013. +**TM-ESC-034**: `HostMounts` normalizes both recorded VFS mount points and each +absolute lookup path before matching. Parent components therefore affect mount +selection exactly as they do for VFS operations and never reach the host-path +join. Regression tests: `parent_components_are_normalized_before_mount_selection` +and `mount_points_are_normalized_when_the_table_is_built`. + #### 2.2 Process Escape | ID | Threat | Attack Vector | Mitigation | Status |