Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
11 changes: 10 additions & 1 deletion crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1729,7 +1729,13 @@ impl HostMounts {
/// mounted.
pub fn new(mounts: impl IntoIterator<Item = HostMount>) -> Self {
Self {
mounts: mounts.into_iter().collect(),
mounts: mounts
.into_iter()
.map(|mut mount| {
mount.vfs_path = normalize_path(&mount.vfs_path);
mount
})
.collect(),
}
}

Expand Down Expand Up @@ -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| {
Expand Down
31 changes: 31 additions & 0 deletions crates/bashkit/tests/integration/host_mounts_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion knowledge/foundations/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |
Expand Down Expand Up @@ -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 |
Expand Down