Skip to content

Commit 5d4ea61

Browse files
author
Paul C
committed
v25.2.49: read-only file browser + Browse button for VM ISO field (klas)
2 parents 4d2f077 + 03b8fda commit 5d4ea61

7 files changed

Lines changed: 422 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "25.2.48"
3+
version = "25.2.49"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13959,6 +13959,33 @@ pub async fn net_list_interfaces(req: HttpRequest, state: web::Data<AppState>) -
1395913959
HttpResponse::Ok().json(networking::list_interfaces())
1396013960
}
1396113961

13962+
/// GET /api/browse/roots — the directories the operator may browse (the
13963+
/// mounted shares + local ISO/VM stores). Read-only; auth-gated.
13964+
pub async fn browse_roots(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
13965+
if let Err(e) = require_auth(&req, &state) { return e; }
13966+
let roots = web::block(crate::browse::allowed_roots).await;
13967+
match roots {
13968+
Ok(r) => HttpResponse::Ok().json(r),
13969+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
13970+
}
13971+
}
13972+
13973+
#[derive(serde::Deserialize)]
13974+
pub struct BrowseQuery { pub path: String }
13975+
13976+
/// GET /api/browse?path=<abs> — list one directory. Path-jailed to the
13977+
/// allowed roots (canonicalized; rejects `..` and symlink escapes).
13978+
/// Returns metadata only — never file contents. Read-only; auth-gated.
13979+
pub async fn browse_dir(req: HttpRequest, state: web::Data<AppState>, q: web::Query<BrowseQuery>) -> HttpResponse {
13980+
if let Err(e) = require_auth(&req, &state) { return e; }
13981+
let path = q.into_inner().path;
13982+
match web::block(move || crate::browse::list_dir(&path)).await {
13983+
Ok(Ok(listing)) => HttpResponse::Ok().json(listing),
13984+
Ok(Err(msg)) => HttpResponse::Forbidden().json(serde_json::json!({ "error": msg })),
13985+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
13986+
}
13987+
}
13988+
1396213989
/// GET /api/network-map — this node's OBSERVED network view for the
1396313990
/// read-only cluster Network Map. Returns the passive topology (bridges,
1396413991
/// interfaces, VLANs, VMs + containers with their IPs and attachments,
@@ -40082,6 +40109,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
4008240109
// Networking
4008340110
.route("/api/networking/interfaces", web::get().to(net_list_interfaces))
4008440111
.route("/api/network-map", web::get().to(network_map))
40112+
.route("/api/browse/roots", web::get().to(browse_roots))
40113+
.route("/api/browse", web::get().to(browse_dir))
4008540114
.route("/api/networking/bridges", web::get().to(net_list_bridges))
4008640115
.route("/api/networking/lan-bridge", web::post().to(net_lan_bridge_create))
4008740116
.route("/api/networking/lan-bridge/confirm", web::post().to(net_lan_bridge_confirm))

src/browse.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod vr_terminal;
2323
mod storage;
2424
mod networking;
2525
mod backup;
26+
mod browse;
2627
mod galera;
2728
mod postgres_ha;
2829
mod mail_tier;

web/index.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10324,10 +10324,13 @@ <h3>Create Virtual Machine</h3>
1032410324
</div>
1032510325
<div class="form-group" style="margin-top:12px;">
1032610326
<label>Installation ISO (Optional)</label>
10327-
<input type="text" class="form-control" id="new-vm-iso"
10328-
placeholder="/var/lib/wolfstack/isos/ubuntu.iso"
10329-
oninput="autoDetectWindowsIso(this.value)">
10330-
<small style="color:var(--text-muted);">Path to an OS installer ISO on the host. Windows ISOs are detected automatically.</small>
10327+
<div style="display:flex; gap:8px;">
10328+
<input type="text" class="form-control" id="new-vm-iso" style="flex:1;"
10329+
placeholder="/var/lib/wolfstack/isos/ubuntu.iso"
10330+
oninput="autoDetectWindowsIso(this.value)">
10331+
<button type="button" class="btn btn-sm" style="white-space:nowrap;" onclick="pickFile({title:'Choose an ISO', filter:'.iso', onPick:function(p){var el=document.getElementById('new-vm-iso'); el.value=p; autoDetectWindowsIso(p);}})">📁 Browse…</button>
10332+
</div>
10333+
<small style="color:var(--text-muted);">Browse a mounted share/storage for the ISO, or type a host path. Windows ISOs are detected automatically.</small>
1033110334
</div>
1033210335
<div class="form-group" style="margin-top:12px;">
1033310336
<label>Import Disk Image (Optional)</label>

0 commit comments

Comments
 (0)