|
| 1 | +// Written by Paul Clevett |
| 2 | +// (C)Copyright Wolf Software Systems Ltd |
| 3 | + |
| 4 | +//! Read-only file browser backend. |
| 5 | +//! |
| 6 | +//! Powers the "Browse…" picker used wherever the UI would otherwise make |
| 7 | +//! the operator type an exact path (VM ISO, backup destinations, storage, |
| 8 | +//! …). It ONLY reads directory listings — never file contents, never any |
| 9 | +//! mutation. |
| 10 | +//! |
| 11 | +//! SECURITY: this exposes the host filesystem, so every listing is jailed |
| 12 | +//! to an allow-list of roots (the WolfStack storage mounts — i.e. the |
| 13 | +//! mounted NFS/SMB/S3 shares — plus the local ISO/VM stores that exist). |
| 14 | +//! A requested path is `canonicalize`d (which resolves `..` AND symlinks) |
| 15 | +//! and must land inside one of the canonicalized roots, so neither |
| 16 | +//! `../../etc` nor a symlink pointing out of a share can escape the jail. |
| 17 | +
|
| 18 | +use std::path::{Path, PathBuf}; |
| 19 | +use serde::Serialize; |
| 20 | + |
| 21 | +/// A directory the operator is allowed to start browsing from. |
| 22 | +#[derive(Debug, Clone, Serialize)] |
| 23 | +pub struct BrowseRoot { |
| 24 | + pub label: String, |
| 25 | + pub path: String, |
| 26 | + /// Human hint for the UI icon/grouping: "share" | "local". |
| 27 | + pub kind: String, |
| 28 | +} |
| 29 | + |
| 30 | +/// One entry in a directory listing (metadata only — no contents). |
| 31 | +#[derive(Debug, Clone, Serialize)] |
| 32 | +pub struct BrowseEntry { |
| 33 | + pub name: String, |
| 34 | + pub path: String, |
| 35 | + pub is_dir: bool, |
| 36 | + pub size: u64, |
| 37 | + /// Unix mtime seconds (0 if unavailable). |
| 38 | + pub mtime: u64, |
| 39 | +} |
| 40 | + |
| 41 | +/// The full listing of a directory plus its (still-in-jail) parent. |
| 42 | +#[derive(Debug, Clone, Serialize)] |
| 43 | +pub struct BrowseListing { |
| 44 | + pub path: String, |
| 45 | + /// Parent directory, or None when `path` is a root (or the parent |
| 46 | + /// would leave the jail) so the UI knows whether "up" is allowed. |
| 47 | + pub parent: Option<String>, |
| 48 | + pub entries: Vec<BrowseEntry>, |
| 49 | +} |
| 50 | + |
| 51 | +/// Local directories that commonly hold ISOs / VM disks. Included as roots |
| 52 | +/// only when they actually exist on this host. |
| 53 | +const LOCAL_ROOTS: &[(&str, &str)] = &[ |
| 54 | + ("VM storage", "/var/lib/wolfstack/vms"), |
| 55 | + ("Proxmox ISOs", "/var/lib/vz/template/iso"), |
| 56 | + ("Proxmox backups", "/var/lib/vz/dump"), |
| 57 | + ("libvirt images", "/var/lib/libvirt/images"), |
| 58 | +]; |
| 59 | + |
| 60 | +/// The directories the operator may browse: every live WolfStack storage |
| 61 | +/// mount (the mounted shares) plus the local stores above that exist. |
| 62 | +pub fn allowed_roots() -> Vec<BrowseRoot> { |
| 63 | + let mut roots = Vec::new(); |
| 64 | + for m in crate::storage::list_mounts() { |
| 65 | + // Only offer mounts that are actually mounted right now — a stale |
| 66 | + // config entry for an unmounted share would just error on browse. |
| 67 | + if crate::storage::check_mounted(&m.mount_point) { |
| 68 | + roots.push(BrowseRoot { |
| 69 | + label: if m.name.is_empty() { m.mount_point.clone() } else { m.name.clone() }, |
| 70 | + path: m.mount_point.clone(), |
| 71 | + kind: "share".to_string(), |
| 72 | + }); |
| 73 | + } |
| 74 | + } |
| 75 | + for (label, p) in LOCAL_ROOTS { |
| 76 | + if Path::new(p).is_dir() { |
| 77 | + roots.push(BrowseRoot { label: (*label).to_string(), path: (*p).to_string(), kind: "local".to_string() }); |
| 78 | + } |
| 79 | + } |
| 80 | + roots |
| 81 | +} |
| 82 | + |
| 83 | +/// Canonicalized root prefixes for the jail check. Recomputed per request |
| 84 | +/// so a newly-mounted share is browsable immediately (and an unmounted one |
| 85 | +/// drops out). Canonicalization resolves symlinks so the roots themselves |
| 86 | +/// can't be spoofed. |
| 87 | +fn root_prefixes() -> Vec<PathBuf> { |
| 88 | + allowed_roots() |
| 89 | + .into_iter() |
| 90 | + .filter_map(|r| std::fs::canonicalize(&r.path).ok()) |
| 91 | + .collect() |
| 92 | +} |
| 93 | + |
| 94 | +/// Whether an already-canonicalized path sits inside one of the allowed |
| 95 | +/// (also-canonicalized) roots. `starts_with` is component-wise, so |
| 96 | +/// `/mnt/a` does NOT match `/mnt/abc`. |
| 97 | +fn within(canon: &Path, roots: &[PathBuf]) -> bool { |
| 98 | + roots.iter().any(|root| canon == *root || canon.starts_with(root)) |
| 99 | +} |
| 100 | + |
| 101 | +/// Convenience wrapper (test-only): canonicalize `path` then check the |
| 102 | +/// jail. Production callers canonicalize once and reuse the result via |
| 103 | +/// [`within`]; the tests exercise the resolve-then-check in one step. |
| 104 | +#[cfg(test)] |
| 105 | +fn is_within(path: &str, roots: &[PathBuf]) -> bool { |
| 106 | + match std::fs::canonicalize(path) { |
| 107 | + Ok(canon) => within(&canon, roots), |
| 108 | + Err(_) => false, |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +/// List a directory. Errors if the path is outside the jail or unreadable. |
| 113 | +/// Hard-capped so a pathological directory can't return an unbounded body. |
| 114 | +/// |
| 115 | +/// SECURITY: every returned entry is re-resolved through symlinks and |
| 116 | +/// re-jailed, and its `path` is the CANONICAL (fully-resolved) location — |
| 117 | +/// so a symlink inside a share that points at `/etc/shadow` (or a raw |
| 118 | +/// device) is never listed, and the path a caller selects can never |
| 119 | +/// resolve outside the allowed roots. The directory itself is read from |
| 120 | +/// its canonical form to close any check→read symlink-swap race. |
| 121 | +pub fn list_dir(path: &str) -> Result<BrowseListing, String> { |
| 122 | + let roots = root_prefixes(); |
| 123 | + let outside = || "That location is outside the storage areas WolfStack can browse.".to_string(); |
| 124 | + |
| 125 | + // Canonicalize the requested directory ONCE and both validate and read |
| 126 | + // that resolved path — no gap for a symlink swap between the two. |
| 127 | + let canon_dir = std::fs::canonicalize(path).map_err(|_| outside())?; |
| 128 | + if !within(&canon_dir, &roots) { |
| 129 | + return Err(outside()); |
| 130 | + } |
| 131 | + let rd = std::fs::read_dir(&canon_dir).map_err(|e| format!("Can't read directory: {}", e))?; |
| 132 | + |
| 133 | + let mut entries: Vec<BrowseEntry> = Vec::new(); |
| 134 | + for e in rd.flatten() { |
| 135 | + // Resolve the entry through any symlinks and RE-JAIL it. A symlink |
| 136 | + // escaping the roots (or a broken/unreadable one) is silently |
| 137 | + // dropped, never surfaced — so it can't become an out-of-jail path |
| 138 | + // once selected. |
| 139 | + let canon = match std::fs::canonicalize(e.path()) { Ok(c) => c, Err(_) => continue }; |
| 140 | + if !within(&canon, &roots) { continue; } |
| 141 | + // `metadata()` FOLLOWS symlinks (unlike DirEntry::metadata), so a |
| 142 | + // symlinked directory reads as a directory and sizes are the real |
| 143 | + // target's. |
| 144 | + let md = match std::fs::metadata(&canon) { Ok(m) => m, Err(_) => continue }; |
| 145 | + let mtime = md.modified().ok() |
| 146 | + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) |
| 147 | + .map(|d| d.as_secs()) |
| 148 | + .unwrap_or(0); |
| 149 | + entries.push(BrowseEntry { |
| 150 | + name: e.file_name().to_string_lossy().to_string(), |
| 151 | + path: canon.to_string_lossy().to_string(), |
| 152 | + is_dir: md.is_dir(), |
| 153 | + size: md.len(), |
| 154 | + mtime, |
| 155 | + }); |
| 156 | + if entries.len() >= 5000 { break; } |
| 157 | + } |
| 158 | + // Directories first, then case-insensitive by name. |
| 159 | + entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir) |
| 160 | + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); |
| 161 | + |
| 162 | + // Offer a parent only if it too stays inside the jail (so the UI's |
| 163 | + // "up" never walks the operator out of an allowed root). |
| 164 | + let parent = canon_dir.parent() |
| 165 | + .filter(|p| within(p, &roots)) |
| 166 | + .map(|p| p.to_string_lossy().to_string()); |
| 167 | + |
| 168 | + Ok(BrowseListing { path: canon_dir.to_string_lossy().to_string(), parent, entries }) |
| 169 | +} |
| 170 | + |
| 171 | +#[cfg(test)] |
| 172 | +mod tests { |
| 173 | + use super::*; |
| 174 | + use std::path::PathBuf; |
| 175 | + |
| 176 | + #[test] |
| 177 | + fn jail_rejects_traversal_and_symlink_escape() { |
| 178 | + // Use the OS temp dir as a fake root, then prove nothing outside it |
| 179 | + // passes the gate. |
| 180 | + let root = std::env::temp_dir(); |
| 181 | + let canon_root = std::fs::canonicalize(&root).expect("temp dir canonicalizes"); |
| 182 | + let roots = vec![canon_root.clone()]; |
| 183 | + |
| 184 | + // The root itself is allowed. |
| 185 | + assert!(is_within(canon_root.to_str().unwrap(), &roots)); |
| 186 | + |
| 187 | + // A path clearly outside the root is rejected. |
| 188 | + assert!(!is_within("/etc", &roots)); |
| 189 | + assert!(!is_within("/etc/shadow", &roots)); |
| 190 | + |
| 191 | + // Traversal that resolves outside the root is rejected (canonicalize |
| 192 | + // collapses the `..` before the prefix check). |
| 193 | + let escape = format!("{}/../../../../etc", canon_root.display()); |
| 194 | + assert!(!is_within(&escape, &roots)); |
| 195 | + |
| 196 | + // A non-existent path can't be canonicalized → rejected, never |
| 197 | + // silently allowed. |
| 198 | + assert!(!is_within("/no/such/path/anywhere-xyz", &roots)); |
| 199 | + } |
| 200 | + |
| 201 | + #[test] |
| 202 | + fn empty_roots_allow_nothing() { |
| 203 | + let roots: Vec<PathBuf> = Vec::new(); |
| 204 | + assert!(!is_within("/", &roots)); |
| 205 | + assert!(!is_within(std::env::temp_dir().to_str().unwrap(), &roots)); |
| 206 | + } |
| 207 | + |
| 208 | + // The security crux: a REAL symlink inside a root that points OUT of it |
| 209 | + // must resolve outside the jail (→ list_dir drops it), while a symlink |
| 210 | + // to a file inside the root stays in the jail. |
| 211 | + #[cfg(unix)] |
| 212 | + #[test] |
| 213 | + fn real_symlink_escaping_root_is_rejected() { |
| 214 | + use std::os::unix::fs::symlink; |
| 215 | + let root = std::env::temp_dir().join(format!("wsbrowse-{}-{}", std::process::id(), "sym")); |
| 216 | + let _ = std::fs::remove_dir_all(&root); |
| 217 | + std::fs::create_dir_all(&root).expect("mk root"); |
| 218 | + let canon_root = std::fs::canonicalize(&root).expect("canon root"); |
| 219 | + let roots = vec![canon_root.clone()]; |
| 220 | + |
| 221 | + // Symlink inside the root → /etc (outside): resolved target is |
| 222 | + // outside the jail, so `within` rejects it. |
| 223 | + let escape = root.join("escape"); |
| 224 | + symlink("/etc", &escape).expect("mk escape link"); |
| 225 | + let canon_escape = std::fs::canonicalize(&escape).expect("canon escape"); |
| 226 | + assert!(!within(&canon_escape, &roots), "symlink to /etc must be outside the jail"); |
| 227 | + |
| 228 | + // Symlink inside the root → a file inside the root: stays in the jail. |
| 229 | + let real = root.join("real.iso"); |
| 230 | + std::fs::write(&real, b"x").expect("write real"); |
| 231 | + let good = root.join("good.iso"); |
| 232 | + symlink(&real, &good).expect("mk good link"); |
| 233 | + let canon_good = std::fs::canonicalize(&good).expect("canon good"); |
| 234 | + assert!(within(&canon_good, &roots), "symlink to an in-jail file is allowed"); |
| 235 | + |
| 236 | + let _ = std::fs::remove_dir_all(&root); |
| 237 | + } |
| 238 | +} |
0 commit comments