Skip to content

Commit 0582bac

Browse files
committed
Mask denied files with /dev/null bind instead of tmpfs, add new test configs
1 parent 25e504e commit 0582bac

5 files changed

Lines changed: 248 additions & 7 deletions

File tree

src/backends/bubblewrap/common/src/bwrap_command.rs

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
//! arguments without spawning any processes, so it compiles and can be
88
//! unit-tested on every host (Windows, macOS, Linux).
99
10+
use std::collections::HashSet;
11+
1012
use wxc_common::filesystem_resolve::FsIntent;
1113
use wxc_common::models::{ExecutionRequest, NetworkPolicy, ProxyAddress};
1214

@@ -97,6 +99,19 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
9799
"/mnt/wsl/resolv.conf",
98100
];
99101

102+
/// Build the complete `bwrap` argument list, masking **every** denied path as a
103+
/// directory (`--tmpfs`).
104+
///
105+
/// This is the pure, classification-free entry point: it performs no filesystem
106+
/// I/O and so stays unit-testable on every host. Unit tests and any caller that
107+
/// has not stat'd the denied paths use it. The Bubblewrap runner uses
108+
/// [`build_args_classified`] instead, passing the set of denied paths it has
109+
/// determined to be files so those are masked with `--ro-bind /dev/null` (a
110+
/// `--tmpfs` over a file would replace it with an empty directory).
111+
pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>) -> Vec<String> {
112+
build_args_classified(request, proxy_address, &HashSet::new())
113+
}
114+
100115
/// Build the complete argument list for `bwrap` from the given request.
101116
///
102117
/// The returned vector does **not** include the `bwrap` binary name itself —
@@ -110,7 +125,18 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
110125
/// - strips any caller-supplied `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`
111126
/// entries from `request.env`,
112127
/// - emits `--setenv` for those keys pointing at the proxy URL.
113-
pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>) -> Vec<String> {
128+
///
129+
/// `denied_files` is the set of `deniedPaths` entries that must be masked as
130+
/// **files** (`--ro-bind /dev/null <path>`) rather than **directories**
131+
/// (`--tmpfs <path>`); any denied path not in the set is masked as a directory.
132+
/// The runner builds this set by `symlink_metadata`-probing each denied path, so
133+
/// this function itself performs no filesystem I/O and remains unit-testable on
134+
/// every host.
135+
pub fn build_args_classified(
136+
request: &ExecutionRequest,
137+
proxy_address: Option<&ProxyAddress>,
138+
denied_files: &HashSet<String>,
139+
) -> Vec<String> {
114140
// -- Namespace isolation (all unshared by default) ---------------------
115141
let mut args = vec![
116142
"--unshare-user",
@@ -182,9 +208,17 @@ pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddres
182208
FsIntent::ReadOnly => {
183209
args.extend(["--ro-bind".into(), mount.path.clone(), mount.path.clone()]);
184210
}
185-
// Denied: mask with an empty tmpfs so contents are invisible.
211+
// Denied: mask so contents are invisible. A directory is masked
212+
// with an empty `--tmpfs`; a file must be masked with
213+
// `--ro-bind /dev/null` instead, because `--tmpfs` over a file would
214+
// replace it with an empty *directory* (wrong type). `denied_files`
215+
// holds the paths the runner classified as non-directories.
186216
FsIntent::Denied => {
187-
args.extend(["--tmpfs".into(), mount.path.clone()]);
217+
if denied_files.contains(&mount.path) {
218+
args.extend(["--ro-bind".into(), "/dev/null".into(), mount.path.clone()]);
219+
} else {
220+
args.extend(["--tmpfs".into(), mount.path.clone()]);
221+
}
188222
}
189223
}
190224
}
@@ -374,6 +408,74 @@ mod tests {
374408
);
375409
}
376410

411+
/// A denied path classified as a **directory** (not in `denied_files`) is
412+
/// masked with an empty `--tmpfs`, matching the default `build_args`.
413+
#[test]
414+
fn denied_directory_is_masked_with_tmpfs() {
415+
let mut r = base_request();
416+
r.policy.denied_paths = vec!["/secrets".into()];
417+
let denied_files = HashSet::new();
418+
let args = build_args_classified(&r, None, &denied_files);
419+
420+
// tmpfs at /secrets, and no ro-bind of /dev/null onto it.
421+
policy_mount_pos(&args, "--tmpfs", "/secrets");
422+
assert!(
423+
args.windows(3)
424+
.all(|w| !(w[0] == "--ro-bind" && w[1] == "/dev/null" && w[2] == "/secrets")),
425+
"a directory denied path must not be masked with /dev/null: {args:?}"
426+
);
427+
}
428+
429+
/// A denied path classified as a **file** (present in `denied_files`) is
430+
/// masked with `--ro-bind /dev/null`, not `--tmpfs` (which would replace the
431+
/// file with an empty directory).
432+
#[test]
433+
fn denied_file_is_masked_with_dev_null() {
434+
let mut r = base_request();
435+
r.policy.denied_paths = vec!["/etc/shadow".into()];
436+
let denied_files = HashSet::from(["/etc/shadow".to_string()]);
437+
let args = build_args_classified(&r, None, &denied_files);
438+
439+
// `--ro-bind /dev/null /etc/shadow` present ...
440+
let pos = args
441+
.windows(3)
442+
.position(|w| w[0] == "--ro-bind" && w[1] == "/dev/null" && w[2] == "/etc/shadow");
443+
assert!(
444+
pos.is_some(),
445+
"a file denied path must be masked with `--ro-bind /dev/null`: {args:?}"
446+
);
447+
// ... and it is NOT tmpfs-masked.
448+
assert!(
449+
args.windows(2)
450+
.all(|w| !(w[0] == "--tmpfs" && w[1] == "/etc/shadow")),
451+
"a file denied path must not be tmpfs-masked: {args:?}"
452+
);
453+
}
454+
455+
/// Classification is per-path: in one policy a denied file and a denied
456+
/// directory get their respective masks, and the specificity ordering is
457+
/// still honored (deep file mask emitted after its shallower rw ancestor).
458+
#[test]
459+
fn mixed_denied_file_and_dir_masks_each_correctly() {
460+
let mut r = base_request();
461+
r.policy.readwrite_paths = vec!["/data".into()];
462+
r.policy.denied_paths = vec!["/data/secret.txt".into(), "/cache".into()];
463+
let denied_files = HashSet::from(["/data/secret.txt".to_string()]);
464+
let args = build_args_classified(&r, None, &denied_files);
465+
466+
// File → /dev/null, after the rw /data parent.
467+
let parent = policy_mount_pos(&args, "--bind", "/data");
468+
let file_mask = policy_mount_pos(&args, "--ro-bind", "/data/secret.txt");
469+
assert!(
470+
file_mask > parent,
471+
"deep denied file mask (pos {file_mask}) must come after rw /data (pos {parent}): {args:?}"
472+
);
473+
assert_eq!(args[file_mask + 1], "/dev/null");
474+
475+
// Dir → tmpfs.
476+
policy_mount_pos(&args, "--tmpfs", "/cache");
477+
}
478+
377479
#[test]
378480
fn environment_variables_are_set() {
379481
let mut r = base_request();

src/backends/bubblewrap/common/src/bwrap_runner.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
//! the runner uses `--unshare-net` for zero-overhead full isolation
2626
//! without root.
2727
28+
use std::collections::HashSet;
2829
use std::fmt::Write as FmtWrite;
2930
use std::os::unix::process::CommandExt;
3031
use std::process::{Child, ChildStdin, Command, Stdio};
@@ -164,15 +165,35 @@ impl BubblewrapScriptRunner {
164165
}
165166
}
166167

167-
// 2. Build the bwrap argument vector.
168-
let args = bwrap_command::build_args(request, proxy.address());
168+
// 2. Classify denied paths so files are masked correctly. A `--tmpfs`
169+
// over a file would replace it with an empty directory; denied files
170+
// must instead be masked with `--ro-bind /dev/null`. Classify with
171+
// `symlink_metadata`, which does not follow symlinks, so each path is
172+
// judged by its own type: a denied symlink is therefore treated as a
173+
// non-directory and masked with `/dev/null`, rather than following
174+
// the link to classify (and mask) its target. Directories, and paths
175+
// that cannot be stat'd (missing/unreadable), fall back to `--tmpfs`.
176+
let denied_files: HashSet<String> = request
177+
.policy
178+
.denied_paths
179+
.iter()
180+
.filter(|p| {
181+
std::fs::symlink_metadata(p)
182+
.map(|md| !md.file_type().is_dir())
183+
.unwrap_or(false)
184+
})
185+
.cloned()
186+
.collect();
187+
188+
// 3. Build the bwrap argument vector.
189+
let args = bwrap_command::build_args_classified(request, proxy.address(), &denied_files);
169190
let _ = writeln!(
170191
logger,
171192
"Bubblewrap: spawning bwrap with {} args",
172193
args.len()
173194
);
174195

175-
// 3. Determine whether iptables network rules are needed. When the
196+
// 4. Determine whether iptables network rules are needed. When the
176197
// cooperative proxy is active we skip iptables entirely (host
177198
// enforcement happens at the proxy layer).
178199
let needs_iptables = needs_iptables_rules(request) && !proxy.is_active();
@@ -209,7 +230,7 @@ impl BubblewrapScriptRunner {
209230
None
210231
};
211232

212-
// 4. Spawn `bwrap`.
233+
// 5. Spawn `bwrap`.
213234
let mut command = Command::new("bwrap");
214235
command.args(&args);
215236
match stdio {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"version": "0.8.0-alpha",
3+
"containerId": "CLI-Bubblewrap-Denied-Masking",
4+
"containment": "bubblewrap",
5+
"process": {
6+
"commandLine": "B=/mnt/masktest; if cat \"$B/visible.txt\" 2>/dev/null | grep -q VISIBLE_SECRET; then echo VISIBLE_OK; else echo VISIBLE_MISSING; fi; if cat \"$B/secret_file.txt\" 2>/dev/null | grep -q FILE_SECRET; then echo FILE_LEAK; else echo FILE_MASKED_OK; fi; if [ -d \"$B/secret_file.txt\" ]; then echo FILE_IS_DIR_BUG; else echo FILE_NOT_DIR_OK; fi; if ls \"$B/secret_dir\" 2>/dev/null | grep -q .; then echo DIR_LEAK; else echo DIR_MASKED_OK; fi"
7+
},
8+
"filesystem": {
9+
"readwritePaths": ["/mnt/masktest"],
10+
"deniedPaths": ["/mnt/masktest/secret_file.txt", "/mnt/masktest/secret_dir"]
11+
}
12+
}

tests/scripts/run_bwrap_all_tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ run_test "Basic Bubblewrap" "$SCRIPT_DIR/run_bwrap_basic_test.sh"
3737
run_test "Bubblewrap Filesystem" "$SCRIPT_DIR/run_bwrap_filesystem_test.sh"
3838
run_test "Bubblewrap Object Validation" "$SCRIPT_DIR/run_bwrap_filesystem_object_test.sh"
3939
run_test "Bubblewrap Most-Specific Path" "$SCRIPT_DIR/run_bwrap_most_specific_test.sh"
40+
run_test "Bubblewrap Denied Masking" "$SCRIPT_DIR/run_bwrap_denied_masking_test.sh"
4041
run_test "Bubblewrap Network Block" "$SCRIPT_DIR/run_bwrap_network_test.sh"
4142
run_test "Bubblewrap Network Proxy" "$SCRIPT_DIR/run_bwrap_network_proxy_test.sh"
4243
run_test "Linux Process Default" "$SCRIPT_DIR/run_linux_process_default_test.sh"
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/bin/bash
2+
# Bubblewrap denied-path masking test (roadmap item #1).
3+
#
4+
# Verifies that a denied FILE and a denied DIRECTORY are each masked with the
5+
# correct primitive:
6+
# - A denied directory is masked with a tmpfs (empty, no leak).
7+
# - A denied *file* is masked with a read-only bind of /dev/null. Masking a
8+
# file with tmpfs would turn it into a DIRECTORY (bwrap creates the mount
9+
# point as a dir), which both changes its type and can break tools that
10+
# expect a regular file. Binding /dev/null keeps it a non-directory whose
11+
# content is empty.
12+
#
13+
# Both fixtures hold secret content that is readable on the host, so any leak
14+
# inside the sandbox is attributable to the policy, not a broken fixture. A
15+
# non-denied sibling (visible.txt) inside the same read-write parent acts as a
16+
# positive control: it MUST be readable in the sandbox, proving the parent
17+
# mount is really present so that masking of the denied entries is attributable
18+
# to the deny policy rather than to the tree simply being absent.
19+
set -euo pipefail
20+
21+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
23+
LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec"
24+
25+
if [ ! -f "$LXC_EXEC" ]; then
26+
LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec"
27+
fi
28+
29+
if [ ! -f "$LXC_EXEC" ]; then
30+
echo "Error: lxc-exec not found. Run build.sh first."
31+
exit 1
32+
fi
33+
34+
BASE="/mnt/masktest"
35+
VISIBLE="$BASE/visible.txt"
36+
FILE="$BASE/secret_file.txt"
37+
DIR="$BASE/secret_dir"
38+
39+
cleanup() { sudo rm -rf "$BASE"; }
40+
trap cleanup EXIT
41+
42+
# Set up: a readable sibling (positive control), a denied secret file, and a
43+
# denied directory holding a secret file.
44+
sudo rm -rf "$BASE"
45+
sudo mkdir -p "$DIR"
46+
echo "VISIBLE_SECRET" | sudo tee "$VISIBLE" > /dev/null
47+
echo "FILE_SECRET" | sudo tee "$FILE" > /dev/null
48+
echo "DIR_SECRET" | sudo tee "$DIR/inner.txt" > /dev/null
49+
50+
# Sanity: all fixtures are readable on the host.
51+
if ! sudo cat "$VISIBLE" | grep -q "VISIBLE_SECRET"; then
52+
echo "FAIL: fixture setup — visible.txt not readable on host."
53+
exit 1
54+
fi
55+
if ! sudo cat "$FILE" | grep -q "FILE_SECRET"; then
56+
echo "FAIL: fixture setup — secret_file.txt not readable on host."
57+
exit 1
58+
fi
59+
if ! sudo cat "$DIR/inner.txt" | grep -q "DIR_SECRET"; then
60+
echo "FAIL: fixture setup — secret_dir/inner.txt not readable on host."
61+
exit 1
62+
fi
63+
64+
echo "Running Bubblewrap denied-path masking test (denied file + denied dir)..."
65+
OUTPUT=$("$LXC_EXEC" --experimental \
66+
"$REPO_DIR/tests/configs/bubblewrap_denied_masking.json" 2>&1 || true)
67+
echo "$OUTPUT"
68+
69+
FAIL=0
70+
71+
# Positive control: the non-denied sibling must be readable, proving the parent
72+
# mount is present (so masking below is attributable to the deny, not absence).
73+
if echo "$OUTPUT" | grep -q "VISIBLE_OK" && ! echo "$OUTPUT" | grep -q "VISIBLE_MISSING"; then
74+
echo "PASS: non-denied sibling readable (parent mount present)."
75+
else
76+
echo "FAIL: non-denied sibling not readable — parent mount missing, test inconclusive."
77+
FAIL=1
78+
fi
79+
80+
if echo "$OUTPUT" | grep -q "FILE_MASKED_OK" && ! echo "$OUTPUT" | grep -q "FILE_LEAK"; then
81+
echo "PASS: denied file content masked."
82+
else
83+
echo "FAIL: denied file content leaked."
84+
FAIL=1
85+
fi
86+
87+
if echo "$OUTPUT" | grep -q "FILE_NOT_DIR_OK" && ! echo "$OUTPUT" | grep -q "FILE_IS_DIR_BUG"; then
88+
echo "PASS: denied file kept as non-directory (masked with /dev/null, not tmpfs)."
89+
else
90+
echo "FAIL: denied file became a directory (tmpfs-over-file bug)."
91+
FAIL=1
92+
fi
93+
94+
if echo "$OUTPUT" | grep -q "DIR_MASKED_OK" && ! echo "$OUTPUT" | grep -q "DIR_LEAK"; then
95+
echo "PASS: denied directory masked empty."
96+
else
97+
echo "FAIL: denied directory content leaked."
98+
FAIL=1
99+
fi
100+
101+
if [ "$FAIL" -ne 0 ]; then
102+
exit 1
103+
fi
104+
105+
echo "Bubblewrap denied-path masking test complete."

0 commit comments

Comments
 (0)