Skip to content

Commit 3381a65

Browse files
committed
Add filesystem-policy delegation check
1 parent 7613ce5 commit 3381a65

5 files changed

Lines changed: 333 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ impl SandboxBackend for BubblewrapScriptRunner {
9999
) -> Result<Box<dyn SandboxProcess>, ScriptResponse> {
100100
validate_common(request)?;
101101
self.validate(request)?;
102+
// Delegation check (D3): reject any policy path the invoking user cannot
103+
// access, so the sandbox never gains access the caller lacks. Done here,
104+
// close to mount, where the real filesystem state is visible (paths may
105+
// not exist at parse time) and the TOCTOU window is minimized.
106+
if let Err(msg) = wxc_common::filesystem_access::check_delegation(&request.policy) {
107+
return Err(ScriptResponse::error(&msg));
108+
}
102109
let child = self.spawn_bwrap(request, logger, stdio)?;
103110
Ok(Box::new(BubblewrapSandboxProcess::new(child)))
104111
}

src/backends/lxc/common/src/lxc_runner.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ impl LxcScriptRunner {
8989

9090
/// Core execution logic.
9191
fn run_internal(&self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse {
92+
// Delegation check (D3): reject any policy path the invoking user cannot
93+
// access, so the sandbox never gains access the caller lacks. Done here,
94+
// close to mount, where the real filesystem state is visible (paths may
95+
// not exist at parse time) and the TOCTOU window is minimized.
96+
if let Err(msg) = wxc_common::filesystem_access::check_delegation(&request.policy) {
97+
return ScriptResponse::error(&msg);
98+
}
99+
92100
// Validate required LXC fields
93101
if self.config.distribution.is_empty() || self.config.release.is_empty() {
94102
return ScriptResponse::error(

src/backends/wslc/common/src/wsl_container_runner.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,15 @@ impl WSLContainerRunner {
857857
) -> ScriptResponse {
858858
let _ = writeln!(logger, "[WSLC] Starting WSL Container runner");
859859

860+
// Delegation check (D3): reject any policy path the invoking user cannot
861+
// access, so the sandbox never gains access the caller lacks. Done here,
862+
// close to mount, where the real filesystem state is visible (paths may
863+
// not exist at parse time) and the TOCTOU window is minimized. On Windows
864+
// this covers directory readwrite paths (the common WSLC case).
865+
if let Err(msg) = wxc_common::filesystem_access::check_delegation(&request.policy) {
866+
return ScriptResponse::error(&msg);
867+
}
868+
860869
// -- Init: COM + SDK + preflight --
861870
let sdk = match Self::init_and_load_sdk(logger) {
862871
Ok(r) => r,
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! Filesystem-policy **delegation check** (roadmap item D3).
5+
//!
6+
//! Enforces that the sandbox is never granted more filesystem access than the
7+
//! invoking user already holds: every `readwritePaths` entry requires the user
8+
//! to have read+write access, and every `readonlyPaths` entry requires read
9+
//! access. `deniedPaths` are unbounded (denying access needs no access) and are
10+
//! not checked. A path the user cannot access is **rejected** so a sandboxed
11+
//! process can't reach files the caller themselves couldn't.
12+
//!
13+
//! This does file I/O (`access(2)` / `CreateFileW`), so — like the object-based
14+
//! normalization in [`crate::filesystem_object`] and per design review — it runs
15+
//! in each backend runner **close to the point of enforcement**, NOT in
16+
//! `config_parser` (which stays string-only). Two reasons:
17+
//!
18+
//! - **Correctness:** mount targets may not exist when the config is parsed
19+
//! (they can be created between parse and launch), so a parse-time check would
20+
//! skip them; checking just before the backend builds its mounts sees the real
21+
//! filesystem state.
22+
//! - **TOCTOU:** doing the check adjacent to enforcement shrinks the window in
23+
//! which the filesystem can change between the check and the mount.
24+
//!
25+
//! When both this and object normalization are wired into a runner,
26+
//! [`crate::filesystem_object::normalize_object_conflicts`] must run **first**,
27+
//! so delegation is checked against the already-tightened intents (a path moved
28+
//! `rw → denied` must not then be required to have write access).
29+
30+
use crate::models::ContainerPolicy;
31+
32+
/// The access the invoking user must hold to delegate a path to the sandbox.
33+
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34+
enum AccessMode {
35+
/// Read access only (for `readonlyPaths`).
36+
Read,
37+
/// Read and write access (for `readwritePaths`).
38+
ReadWrite,
39+
}
40+
41+
impl AccessMode {
42+
/// The JSON list name this mode maps to, and the access phrase, for the
43+
/// rejection message.
44+
fn list_name(self) -> &'static str {
45+
match self {
46+
AccessMode::Read => "readonlyPaths",
47+
AccessMode::ReadWrite => "readwritePaths",
48+
}
49+
}
50+
51+
fn access_phrase(self) -> &'static str {
52+
match self {
53+
AccessMode::Read => "read",
54+
AccessMode::ReadWrite => "read+write",
55+
}
56+
}
57+
}
58+
59+
/// Checks whether the invoking user holds the requested access to `path`.
60+
///
61+
/// Returns `Some(true)` / `Some(false)` when the result is determinable, or
62+
/// `None` when it cannot be determined (e.g. the path does not exist — that case
63+
/// is surfaced separately by the existence warning, so delegation skips it).
64+
///
65+
/// On Unix this uses `access(2)` against the real UID (the invoking user), which
66+
/// covers both files and directories — fully implementing spec D3 for the Linux
67+
/// backends (LXC, Bubblewrap).
68+
///
69+
/// On Windows it probes the caller's effective access with `CreateFileW`
70+
/// (requesting `GENERIC_READ` / `GENERIC_READ | GENERIC_WRITE`, opened with
71+
/// `FILE_FLAG_BACKUP_SEMANTICS` so directories are covered too). A successful
72+
/// open means the access is granted; an `ERROR_ACCESS_DENIED` failure means it
73+
/// is not; any other failure is treated as undeterminable (`None`) and skipped.
74+
/// This covers files *and* directories — including the common WSLC
75+
/// `readwritePaths` directory case — implementing spec D3 for the WSLC backend.
76+
#[cfg(unix)]
77+
fn user_can_access(path: &str, mode: AccessMode) -> Option<bool> {
78+
use std::ffi::CString;
79+
80+
if std::fs::metadata(path).is_err() {
81+
return None;
82+
}
83+
let c_path = CString::new(path).ok()?;
84+
let mask = match mode {
85+
AccessMode::Read => libc::R_OK,
86+
AccessMode::ReadWrite => libc::R_OK | libc::W_OK,
87+
};
88+
// SAFETY: `c_path` is a valid NUL-terminated C string for the duration of the call.
89+
let rc = unsafe { libc::access(c_path.as_ptr(), mask) };
90+
Some(rc == 0)
91+
}
92+
93+
#[cfg(windows)]
94+
fn user_can_access(path: &str, mode: AccessMode) -> Option<bool> {
95+
use windows::core::PCWSTR;
96+
use windows::Win32::Foundation::{
97+
CloseHandle, GetLastError, ERROR_ACCESS_DENIED, GENERIC_READ, GENERIC_WRITE,
98+
};
99+
use windows::Win32::Storage::FileSystem::{
100+
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ,
101+
FILE_SHARE_WRITE, OPEN_EXISTING,
102+
};
103+
104+
// Probe the caller's effective access by asking the OS to open the object
105+
// with the required rights. FILE_FLAG_BACKUP_SEMANTICS lets the same call
106+
// open directories as well as files, so — unlike a plain `File::open` — this
107+
// covers directory WRITE access (mapped by the OS to FILE_ADD_FILE /
108+
// FILE_ADD_SUBDIRECTORY), which is the common WSLC `readwritePaths` case.
109+
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
110+
let desired = match mode {
111+
AccessMode::Read => GENERIC_READ.0,
112+
AccessMode::ReadWrite => GENERIC_READ.0 | GENERIC_WRITE.0,
113+
};
114+
let share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
115+
116+
// SAFETY: `wide` is a local NUL-terminated buffer; all other pointers are NULL.
117+
let handle = unsafe {
118+
CreateFileW(
119+
PCWSTR(wide.as_ptr()),
120+
desired,
121+
share,
122+
None,
123+
OPEN_EXISTING,
124+
FILE_FLAG_BACKUP_SEMANTICS,
125+
None,
126+
)
127+
};
128+
match handle {
129+
Ok(h) if !h.is_invalid() => {
130+
// SAFETY: `h` is a valid handle returned by CreateFileW.
131+
unsafe {
132+
let _ = CloseHandle(h);
133+
}
134+
Some(true)
135+
}
136+
_ => {
137+
// Only an explicit access denial is a delegation failure. Any other
138+
// error (non-existent path — surfaced separately by the existence
139+
// warning — sharing violation, etc.) is undeterminable and skipped
140+
// rather than rejected, matching the Unix `None`-on-missing behavior.
141+
// SAFETY: reads the thread-local last error set by the failed call above.
142+
let err = unsafe { GetLastError() };
143+
if err == ERROR_ACCESS_DENIED {
144+
Some(false)
145+
} else {
146+
None
147+
}
148+
}
149+
}
150+
}
151+
152+
#[cfg(not(any(unix, windows)))]
153+
fn user_can_access(_path: &str, _mode: AccessMode) -> Option<bool> {
154+
None
155+
}
156+
157+
/// Validates the delegation constraint (spec D3): the sandbox receives no more
158+
/// access than the invoking user holds. `readwritePaths` require the user to
159+
/// have read+write access and `readonlyPaths` require read access; `deniedPaths`
160+
/// are unbounded and not checked. Paths whose access cannot be determined (e.g.
161+
/// non-existent paths) are skipped rather than rejected.
162+
///
163+
/// Returns the rejection message for the first path that fails, or `Ok(())` when
164+
/// every checkable path is within the caller's access. Callers surface the
165+
/// message as their backend-appropriate error (e.g. `ScriptResponse::error`).
166+
pub fn check_delegation(policy: &ContainerPolicy) -> Result<(), String> {
167+
for (paths, mode) in [
168+
(&policy.readonly_paths, AccessMode::Read),
169+
(&policy.readwrite_paths, AccessMode::ReadWrite),
170+
] {
171+
for path in paths {
172+
if user_can_access(path, mode) == Some(false) {
173+
return Err(format!(
174+
"Filesystem path '{}' ({}): the invoking user does not have {} access, \
175+
so it cannot be delegated to the sandbox",
176+
path,
177+
mode.list_name(),
178+
mode.access_phrase(),
179+
));
180+
}
181+
}
182+
}
183+
Ok(())
184+
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
fn policy(rw: &[&str], ro: &[&str]) -> ContainerPolicy {
191+
ContainerPolicy {
192+
readwrite_paths: rw.iter().map(|s| s.to_string()).collect(),
193+
readonly_paths: ro.iter().map(|s| s.to_string()).collect(),
194+
..Default::default()
195+
}
196+
}
197+
198+
#[test]
199+
fn accessible_file_is_delegable() {
200+
let dir = tempfile::tempdir().unwrap();
201+
let file = dir.path().join("data.txt");
202+
std::fs::write(&file, b"test").unwrap();
203+
let f = file.to_str().unwrap();
204+
205+
assert_eq!(user_can_access(f, AccessMode::Read), Some(true));
206+
assert_eq!(user_can_access(f, AccessMode::ReadWrite), Some(true));
207+
assert!(check_delegation(&policy(&[f], &[])).is_ok());
208+
}
209+
210+
#[test]
211+
fn accessible_directory_is_delegable() {
212+
// Directory read+write is the common WSLC `readwritePaths` case; it must
213+
// be enforced on both Unix and Windows.
214+
let dir = tempfile::tempdir().unwrap();
215+
let d = dir.path().to_str().unwrap();
216+
217+
assert_eq!(user_can_access(d, AccessMode::Read), Some(true));
218+
assert_eq!(user_can_access(d, AccessMode::ReadWrite), Some(true));
219+
assert!(check_delegation(&policy(&[d], &[])).is_ok());
220+
}
221+
222+
#[test]
223+
fn nonexistent_path_is_skipped() {
224+
// A non-existent path can't be access-checked; delegation skips it
225+
// (existence is surfaced separately as a warning, not a delegation error).
226+
assert_eq!(
227+
user_can_access("/definitely/not/here/mxc-xyz", AccessMode::Read),
228+
None
229+
);
230+
assert!(check_delegation(&policy(&["/definitely/not/here/mxc-xyz"], &[])).is_ok());
231+
}
232+
233+
#[test]
234+
fn empty_policy_is_ok() {
235+
assert!(check_delegation(&ContainerPolicy::default()).is_ok());
236+
}
237+
238+
#[cfg(unix)]
239+
#[test]
240+
fn unreadable_path_is_rejected() {
241+
use std::os::unix::fs::PermissionsExt;
242+
243+
// Root bypasses permission checks, so this case is only meaningful as a
244+
// non-root user.
245+
if unsafe { libc::geteuid() } == 0 {
246+
return;
247+
}
248+
249+
let dir = tempfile::tempdir().unwrap();
250+
let file = dir.path().join("secret.txt");
251+
std::fs::write(&file, b"secret").unwrap();
252+
// Remove all permissions so the invoking user cannot read it.
253+
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
254+
let f = file.to_str().unwrap();
255+
256+
assert_eq!(user_can_access(f, AccessMode::Read), Some(false));
257+
let err = check_delegation(&policy(&[], &[f])).unwrap_err();
258+
assert!(
259+
err.contains("does not have read access"),
260+
"expected delegation rejection, got: {err}"
261+
);
262+
263+
// Restore permissions so the tempdir can be cleaned up.
264+
let _ = std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600));
265+
}
266+
267+
#[cfg(windows)]
268+
#[test]
269+
fn unreadable_path_is_rejected() {
270+
use std::process::Command;
271+
272+
let dir = tempfile::tempdir().unwrap();
273+
let file = dir.path().join("secret.txt");
274+
std::fs::write(&file, b"secret").unwrap();
275+
let f = file.to_str().unwrap();
276+
277+
// Deny read to Everyone (well-known SID S-1-1-0 — locale/domain
278+
// independent). A deny ACE blocks FILE_READ_DATA even for the owner
279+
// (whose implicit rights are only READ_CONTROL / WRITE_DAC), so a
280+
// GENERIC_READ open must fail with ERROR_ACCESS_DENIED. Parent-dir full
281+
// control still lets the tempdir delete the child on cleanup.
282+
let status = Command::new("icacls")
283+
.args([f, "/deny", "*S-1-1-0:(R)"])
284+
.output()
285+
.expect("icacls should run");
286+
assert!(
287+
status.status.success(),
288+
"icacls deny failed: {}",
289+
String::from_utf8_lossy(&status.stderr)
290+
);
291+
292+
assert_eq!(
293+
user_can_access(f, AccessMode::Read),
294+
Some(false),
295+
"a path with an explicit deny-read ACE must be reported as inaccessible"
296+
);
297+
let err = check_delegation(&policy(&[], &[f])).unwrap_err();
298+
assert!(
299+
err.contains("does not have read access"),
300+
"expected delegation rejection, got: {err}"
301+
);
302+
303+
// Remove the deny ACE so the tempdir can clean up without surprises.
304+
let _ = Command::new("icacls")
305+
.args([f, "/remove:d", "*S-1-1-0"])
306+
.output();
307+
}
308+
}

src/core/wxc_common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod cmdline;
77
pub mod config_parser;
88
pub mod encoding;
99
pub mod error;
10+
pub mod filesystem_access;
1011
pub mod id;
1112
pub mod log_symbols;
1213
pub mod logger;

0 commit comments

Comments
 (0)