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
9 changes: 9 additions & 0 deletions docs/backends/bwrap/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ Common consequences of this default:
`readonlyPaths` if the script depends on it.
- `working_directory` must live under the baseline or a policy path — a
`cwd` of `~/project` without a matching `readonlyPaths` entry will fail.
At launch, Bubblewrap retains a mount-configuration hint for SDK callers,
which `lxc-exec` also prints to stderr, when an explicit absolute cwd has
no apparent overlap with known mount destinations. This compares path
spellings without inspecting the host cwd or policy paths, so symlinks or
runtime mount behavior can make the hint inapplicable. Check
`readonlyPaths` or `readwritePaths` for a containing directory. Ambiguous
paths (including `..` and paths near policy mounts) are left to Bubblewrap;
silence does not prove a cwd is accessible. The hint does not change
execution or replace Bubblewrap's error.
- DNS works on systemd-resolved, NetworkManager, and resolvconf hosts
because the corresponding `/run/...` directories are bound. The common
symlink targets *outside* `/run` are covered too: `/var/run/...`-routed
Expand Down
150 changes: 150 additions & 0 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,50 @@ fn start_directory(request: &ExecutionRequest) -> Option<String> {
.map(crate::mxc_common::models::sandbox_absolute_path)
}

/// Find an explicit cwd with no overlapping known mount destination.
/// This is a lexical hint, not proof of visibility inside Bubblewrap.
#[cfg(any(target_os = "linux", test))]
pub(crate) fn cwd_without_matching_mount(request: &ExecutionRequest) -> Option<String> {
let dir = start_directory(request)?;
if dir == "/"
|| !request.working_directory.starts_with('/')
|| request
.working_directory
.split('/')
.any(|part| part == "." || part == "..")
{
return None;
}

let mounts = BASELINE_RO_BIND_PATHS
.iter()
.copied()
.chain(["/dev", "/proc", "/tmp", "/var/run"])
.chain(
request
.policy
.readwrite_paths
.iter()
.chain(&request.policy.readonly_paths)
.chain(&request.policy.denied_paths)
.map(String::as_str),
);
for mount in mounts {
if !mount.starts_with('/') || mount.split('/').any(|part| part == "." || part == "..") {
return None;
}
if path_is_within(&dir, mount) || path_is_within(mount, &dir) {
return None;
}
}
Some(dir)
}

#[cfg(any(target_os = "linux", test))]
fn path_is_within(path: &str, parent: &str) -> bool {
std::path::Path::new(path).starts_with(parent)
}
Comment thread
Copilot marked this conversation as resolved.

/// The default environment: `PATH`, `TERM`, and — when one resolves — `HOME`.
///
/// `HOME` names the directory the child actually runs in, so it is a path the
Expand Down Expand Up @@ -571,6 +615,85 @@ mod tests {
}
}

#[test]
fn cwd_hint_requires_a_nonoverlapping_absolute_path() {
let mut request = base_request();
assert_eq!(
cwd_without_matching_mount(&request).as_deref(),
Some("/home/user")
);
request.working_directory = "/".into();
assert_eq!(cwd_without_matching_mount(&request), None);
request.working_directory = "".into();
assert_eq!(cwd_without_matching_mount(&request), None);
request.working_directory = "home/user".into();
assert_eq!(cwd_without_matching_mount(&request), None);
request.working_directory = "/home/../home/user".into();
assert_eq!(cwd_without_matching_mount(&request), None);
}

#[test]
fn cwd_overlap_is_inconclusive_in_either_direction() {
let mut request = base_request();
for cwd in ["/etc", "/etc/project", "/usr", "/tmp/work", "/var"] {
request.working_directory = cwd.into();
assert_eq!(cwd_without_matching_mount(&request), None, "{cwd}");
}
for mount in [
"/home",
"/home/user",
"/home/user/",
"/home//user/",
"/home/user/data",
"/home//user/data/",
] {
request.working_directory = "/home/user".into();
request.policy.readonly_paths = vec![mount.into()];
assert_eq!(cwd_without_matching_mount(&request), None, "{mount}");
}
for mount in ["/home/user2", "/home/user2/"] {
request.policy.readonly_paths = vec![mount.into()];
assert_eq!(
cwd_without_matching_mount(&request).as_deref(),
Some("/home/user"),
"{mount}"
);
}
request.policy.readonly_paths = vec!["/alias/../home/user".into()];
assert_eq!(cwd_without_matching_mount(&request), None);
request.policy.readonly_paths = vec!["/".into()];
assert_eq!(cwd_without_matching_mount(&request), None);
request.policy.readonly_paths.clear();
request.policy.denied_paths = vec!["/home/user/hidden".into()];
assert_eq!(cwd_without_matching_mount(&request), None);
}

#[test]
fn cwd_proof_uses_the_mounted_baseline_and_virtual_roots() {
let request = base_request();
let args = build_args(&request, None);
for mount in BASELINE_RO_BIND_PATHS {
assert!(
args.windows(3)
.any(|a| { a == ["--ro-bind-try", *mount, *mount] }),
"{mount}"
);
}
for (flag, path) in [
("--dev", "/dev"),
("--proc", "/proc"),
("--tmpfs", "/tmp"),
("--symlink", "/var/run"),
] {
assert!(
args.windows(2).any(|a| a[0] == flag && a[1] == path)
|| (flag == "--symlink"
&& args.windows(3).any(|a| a == ["--symlink", "/run", path])),
"{flag} {path}"
);
}
}

#[test]
fn ambiguous_resolver_paths_and_shadowed_link_do_not_warn() {
let mut policy = ContainerPolicy::default();
Expand All @@ -594,6 +717,33 @@ mod tests {
));
}

#[test]
fn cwd_diagnostic_recognizes_every_emitted_mount_destination() {
let mut request = base_request();
let args = build_args(&request, None);
for triple in args.windows(3) {
if matches!(
triple[0].as_str(),
"--ro-bind-try" | "--ro-bind" | "--bind" | "--symlink"
) {
request.working_directory = triple[2].clone();
assert!(
cwd_without_matching_mount(&request).is_none(),
"unrecognized mount destination: {triple:?}"
);
}
}
for pair in args.windows(2) {
if matches!(pair[0].as_str(), "--dev" | "--proc" | "--tmpfs") {
request.working_directory = pair[1].clone();
assert!(
cwd_without_matching_mount(&request).is_none(),
"unrecognized virtual mount destination: {pair:?}"
);
}
}
}

/// `process.env` resolution, which schema 0.9 gave a default block.
mod env {
use super::*;
Expand Down
36 changes: 36 additions & 0 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ impl SandboxBackend for BubblewrapScriptRunner {
return Err(ScriptResponse::error(&msg));
}
warn_resolver_mount_hint(request, logger);
warn_cwd_mount_hint(request, logger);
let child = self.spawn_bwrap(request, &plan.files, egress_plan, logger, stdio)?;
Ok(Box::new(BubblewrapSandboxProcess::new(child)))
}
Expand Down Expand Up @@ -377,6 +378,18 @@ fn warn_resolver_mount_hint_with(
));
}

fn warn_cwd_mount_hint(request: &ExecutionRequest, logger: &mut Logger) {
let Some(dir) = bwrap_command::cwd_without_matching_mount(request) else {
return;
};
logger.warning_line(&format!(
"WARNING: Bubblewrap: process.cwd {dir:?} has no apparent covering mount \
destination. It may be unavailable inside the sandbox; check \
filesystem.readonlyPaths or filesystem.readwritePaths for a containing \
directory. Bubblewrap will decide whether --chdir succeeds."
));
}

impl BubblewrapScriptRunner {
/// Set up networking and spawn `bwrap`, returning a [`BwrapChild`] wrapped
/// by the [`SandboxProcess`] handle. With [`StdioMode::Pipes`] the child's
Expand Down Expand Up @@ -1294,6 +1307,29 @@ mod tests {
}
}

#[test]
fn cwd_mount_hint_is_retained_for_unmatched_paths() {
use crate::mxc_common::logger::Mode;

let mut request = base_request();
request.working_directory = "/home/user".into();
let mut logger = Logger::new(Mode::Buffer);
warn_cwd_mount_hint(&request, &mut logger);
assert_eq!(logger.warnings().len(), 1);
assert!(logger.warnings()[0].contains("no apparent covering mount"));
assert!(logger.warnings()[0].contains("filesystem.readonlyPaths"));
assert!(logger.get_buffer().is_empty());

request.policy.readonly_paths = vec!["/other".into()];
let mut unrelated_logger = Logger::new(Mode::Buffer);
warn_cwd_mount_hint(&request, &mut unrelated_logger);
assert_eq!(unrelated_logger.warnings().len(), 1);
request.policy.readonly_paths = vec!["/home/user/data".into()];
let mut covered_logger = Logger::new(Mode::Buffer);
warn_cwd_mount_hint(&request, &mut covered_logger);
assert!(covered_logger.warnings().is_empty());
}

/// A request carrying `alice:hunter2@` in its proxy URL, built the way a
/// programmatic caller would rather than through the JSON parser.
fn request_with_a_credential_bearing_proxy() -> ExecutionRequest {
Expand Down
1 change: 1 addition & 0 deletions tests/scripts/run_bwrap_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ run_test() {
}

run_test "Basic Bubblewrap" "$SCRIPT_DIR/run_bwrap_basic_test.sh"
run_test "Bubblewrap cwd advisory" "$SCRIPT_DIR/run_bwrap_cwd_advisory_test.sh"
run_test "Bubblewrap Environment" "$SCRIPT_DIR/run_bwrap_environment_test.sh"
run_test "Bubblewrap Filesystem" "$SCRIPT_DIR/run_bwrap_filesystem_test.sh"
run_test "Bubblewrap Read-Only Denial" "$SCRIPT_DIR/run_bwrap_readonly_denial_test.sh"
Expand Down
55 changes: 55 additions & 0 deletions tests/scripts/run_bwrap_cwd_advisory_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/bash
# Check that the CLI reports a cwd mount hint without changing launch results.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
LXC_EXEC="${LXC_EXEC:-$REPO_DIR/src/target/release/lxc-exec}"
if [ ! -f "$LXC_EXEC" ]; then
LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec"
fi
if [ ! -f "$LXC_EXEC" ]; then
echo "Error: lxc-exec not found. Run build.sh first."
exit 1
fi

WORK_DIR=$(mktemp -d "$(cd "$HOME" && pwd -P)/.mxc_bwrap_cwd_XXXXXX")
CWD="$WORK_DIR/work"
UNRELATED="$WORK_DIR/other"
trap 'rm -f "$WORK_DIR/uncovered.json" "$WORK_DIR/unrelated.json" "$WORK_DIR/covered.json" "$WORK_DIR/uncovered.stderr" "$WORK_DIR/unrelated.stderr" "$WORK_DIR/covered.stderr"; rmdir "$CWD" "$UNRELATED" "$WORK_DIR"' EXIT
mkdir "$CWD" "$UNRELATED"

cat >"$WORK_DIR/uncovered.json" <<EOF
{"version":"0.9.0-alpha","containment":"bubblewrap",
"process":{"commandLine":"pwd","cwd":"$CWD"}}
EOF
cat >"$WORK_DIR/covered.json" <<EOF
{"version":"0.9.0-alpha","containment":"bubblewrap",
"process":{"commandLine":"pwd","cwd":"$CWD"},
"filesystem":{"readonlyPaths":["$CWD"]}}
EOF
cat >"$WORK_DIR/unrelated.json" <<EOF
{"version":"0.9.0-alpha","containment":"bubblewrap",
"process":{"commandLine":"pwd","cwd":"$CWD"},
"filesystem":{"readonlyPaths":["$UNRELATED"]}}
EOF

for case in uncovered unrelated; do
rc=0
output=$("$LXC_EXEC" --experimental "$WORK_DIR/$case.json" 2>"$WORK_DIR/$case.stderr") || rc=$?
if [ "$rc" -eq 0 ] \
|| ! grep -Fq "WARNING: Bubblewrap: process.cwd \"$CWD\"" "$WORK_DIR/$case.stderr" \
|| ! grep -Fq 'filesystem.readonlyPaths' "$WORK_DIR/$case.stderr" \
|| ! grep -Eq '(^|[[:space:]])bwrap:' "$WORK_DIR/$case.stderr"; then
echo "$case cwd did not report the hint and Bubblewrap's original error: $output"
cat "$WORK_DIR/$case.stderr"
exit 1
fi
done
output=$("$LXC_EXEC" --experimental "$WORK_DIR/covered.json" 2>"$WORK_DIR/covered.stderr")
if ! grep -qFx "$CWD" <<<"$output" \
|| grep -Fq 'WARNING: Bubblewrap: process.cwd ' "$WORK_DIR/covered.stderr"; then
echo "Covered cwd did not run without an advisory: $output"
cat "$WORK_DIR/covered.stderr"
exit 1
fi
Loading