|
| 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 | +} |
0 commit comments