refactor(#1456): extract dashboard state-file proxy + path-validation into dashboard/state_files.rs

This commit is contained in:
damocles 2026-06-08 23:46:47 +02:00 committed by mara
commit df3058e311
2 changed files with 314 additions and 288 deletions

View file

@ -37,6 +37,7 @@ mod permissions;
mod questions;
mod reminders;
mod schedules;
mod state_files;
mod topology;
mod webhook;
@ -45,6 +46,10 @@ mod webhook;
// re-exported at the module root to preserve the `crate::dashboard::approval_diff`
// path across the submodule split.
pub(crate) use approvals::approval_diff;
// Run at broker-message ingest by the coordinator + the operator-msg path
// (`main.rs`); re-exported to preserve the `crate::dashboard::scan_validated_paths`
// path across the split.
pub use state_files::scan_validated_paths;
#[derive(Clone)]
struct AppState {
@ -94,7 +99,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/api/journal/{name}", get(journal::get_journal))
.route("/api/journal-host", get(journal::get_journal_host))
.route("/api/approval-diff/{id}", get(approvals::get_approval_diff))
.route("/api/state-file", get(get_state_file))
.route("/api/state-file", get(state_files::get_state_file))
.route("/api/reminders", get(reminders::api_reminders))
.route("/api/operator-inbox", get(api_operator_inbox))
.route("/api/stats-hive", get(api_stats_hive))
@ -1081,139 +1086,9 @@ struct RequestSpawnForm {
name: String,
}
#[derive(Deserialize)]
struct StateFileQuery {
path: String,
}
/// Resolve a caller-supplied path against the allow-listed roots
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
/// symlink + traversal checks before serving. Security model and
/// all five layers: `docs/security.md::State-file endpoint`.
fn resolve_state_path(
raw: &str,
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
use std::os::unix::fs::PermissionsExt as _;
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
let raw = raw.trim();
let (mapped, root): (std::path::PathBuf, &str) =
if let Some(rest) = raw.strip_prefix("/agents/") {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix("/shared/") {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else {
return Err(format!("path not in allow-list: {raw}"));
};
reject_symlinks_below(std::path::Path::new(root), &mapped)?;
let canonical =
std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?;
if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) {
return Err(format!(
"resolved path escapes allow-list: {}",
canonical.display()
));
}
if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) {
let mut components = rel.components();
let _agent = components.next();
let dir = components.next().and_then(|c| c.as_os_str().to_str());
if dir != Some("state") {
return Err(format!(
"only per-agent state/ is readable here ({} dir not allowed)",
dir.unwrap_or("(root)")
));
}
}
let meta =
std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?;
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o004 == 0 {
return Err(format!(
"{} not world-readable (mode 0{:o}); refusing to proxy non-public file",
canonical.display(),
mode & 0o777,
));
}
}
Ok((canonical, meta))
}
/// Walk every path component under `root` and refuse if any of
/// them is a symlink. The roots themselves (`AGENTS_ROOT`,
/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only
/// the parts the agent / operator can plant matter. Components
/// that don't exist yet are skipped — `canonicalize` reports
/// non-existence separately, and missing-component checks would
/// just race the filesystem.
fn reject_symlinks_below(
root: &std::path::Path,
mapped: &std::path::Path,
) -> std::result::Result<(), String> {
let Ok(rel) = mapped.strip_prefix(root) else {
return Ok(());
};
let mut cumulative = root.to_path_buf();
for component in rel.components() {
match component {
std::path::Component::Normal(name) => {
cumulative.push(name);
match std::fs::symlink_metadata(&cumulative) {
Ok(m) if m.file_type().is_symlink() => {
return Err(format!(
"symlink at {} not allowed (canonicalize would resolve it past the \
allow-list check; refuse outright)",
cumulative.display()
));
}
Ok(_) | Err(_) => {}
}
}
std::path::Component::ParentDir => {
return Err(format!(
"path contains `..` traversal below {}; refuse outright",
root.display()
));
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// Make a unique tmp subdir for the calling test. Caller is responsible
/// for cleanup (we leak on panic, fine for ephemeral CI runs).
fn tmproot(tag: &str) -> std::path::PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() {
// Reproduce the shape where meta has
@ -1318,61 +1193,6 @@ mod tests {
assert!(validate_agent_name("damóclès").is_some());
assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash
}
#[test]
fn reject_symlinks_below_accepts_plain_dirs_and_files() {
let root = tmproot("symlink-ok");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap();
assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok());
}
#[test]
fn reject_symlinks_below_rejects_leaf_symlink() {
let root = tmproot("symlink-leaf");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
// Plant a symlink that points anywhere; resolve_state_path's
// canonicalize would happily resolve it past the allow-list
// check, so we have to refuse at the un-canonical layer.
symlink("/etc/shadow", root.join("alice/state/peek")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
assert!(err.contains("peek"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_directory_symlink_in_middle() {
let root = tmproot("symlink-mid");
std::fs::create_dir_all(root.join("real/state")).unwrap();
std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap();
// alice's "state" dir is actually a symlink to real/state — a
// sub-agent shouldn't be able to plant this and proxy real's
// private files via the dashboard.
std::fs::create_dir_all(root.join("alice")).unwrap();
symlink(root.join("real/state"), root.join("alice/state")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_parent_dir_traversal() {
let root = tmproot("symlink-dotdot");
// `..` doesn't survive canonicalize anyway, but we want a
// friendlier error than "path escapes allow-list" — refusing
// upfront also avoids walking ancestors with `symlink_metadata`.
let p = root.join("alice/state/../escape");
let err = reject_symlinks_below(&root, &p).unwrap_err();
assert!(err.contains("`..`"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_passes_through_when_path_not_under_root() {
// resolve_state_path's earlier allow-list check would reject
// this; reject_symlinks_below stays a no-op so the caller
// surfaces the better-fit error.
let root = std::path::Path::new("/var/lib/hyperhive/agents");
assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok());
}
}
/// Snapshot the current tombstone list and emit a
@ -1401,108 +1221,6 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
});
}
/// Scan `body` for path-shaped tokens and return those that pass the
/// allow-list + `is_file` check via `resolve_state_path`. Called at
/// broker-message ingest so the dashboard event already carries the
/// verified set; security rules stay in sync with the read endpoint.
pub fn scan_validated_paths(body: &str) -> Vec<String> {
const PREFIXES: [&str; 4] = [
"/agents/",
"/shared/",
"/var/lib/hyperhive/agents/",
"/var/lib/hyperhive/shared/",
];
let mut out = Vec::<String>::new();
for raw in body.split(|c: char| c.is_whitespace()) {
// Trim trailing natural-language punctuation that wouldn't
// be part of any real path. Inline rather than via a regex
// dep — the set is small and the call is hot.
let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']);
if token.is_empty() {
continue;
}
if !PREFIXES.iter().any(|p| token.starts_with(p)) {
continue;
}
// Cheap dedupe — typical message has 0-3 refs.
if out.iter().any(|s| s == token) {
continue;
}
if let Ok((_canonical, meta)) = resolve_state_path(token)
&& meta.is_file()
{
out.push(token.to_owned());
}
}
out
}
async fn get_state_file(axum::extract::Query(q): axum::extract::Query<StateFileQuery>) -> Response {
const MAX_BYTES: usize = 1 << 20; // 1 MiB
let (canonical, meta) = match resolve_state_path(&q.path) {
Ok(pair) => pair,
Err(e) => return error_response(&format!("state-file: {e}")),
};
if !meta.is_file() {
return error_response(&format!(
"state-file: {} is not a regular file",
canonical.display()
));
}
let size = meta.len();
let bytes = match std::fs::read(&canonical) {
Ok(b) => b,
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
};
// Raster images: serve the raw bytes with their real content-type
// so the dashboard can render them in an <img>. Not truncated —
// a clipped binary is corrupt, so over-cap images are rejected
// instead. (SVG stays on the text path: it's text, and the client
// renders it via a data: URI.)
if let Some(ct) = image_content_type(&canonical) {
if bytes.len() > MAX_BYTES {
return error_response(&format!(
"state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap",
canonical.display()
));
}
return ([("content-type", ct)], bytes).into_response();
}
let truncated = bytes.len() > MAX_BYTES;
let body_bytes = if truncated {
&bytes[..MAX_BYTES]
} else {
&bytes[..]
};
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
if truncated {
use std::fmt::Write as _;
let _ = write!(
body,
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
);
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
/// Content-type for a raster image the dashboard can preview in an
/// `<img>`, keyed off the file extension. `None` for non-image, SVG,
/// and text files (SVG is served on the text path and rendered
/// client-side via a `data:` URI).
fn image_content_type(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"avif" => "image/avif",
_ => return None,
})
}
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox
/// (#1469). Returns messages addressed to `"operator"` that haven't been
/// acked yet (the operator clears them via the existing

View file

@ -0,0 +1,308 @@
//! State-file proxy + path-validation for the dashboard.
//!
//! `GET /api/state-file?path=…` serves an allow-listed file (per-agent
//! `state/` or `shared/`) with defense-in-depth symlink + traversal checks
//! (see `docs/security.md::State-file endpoint`); raster images are served
//! with their real content-type, everything else as truncated text.
//! `scan_validated_paths` runs the same allow-list at broker-message ingest
//! so dashboard events carry a pre-verified file-ref set.
use std::path::Path;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use super::error_response;
#[derive(Deserialize)]
pub(super) struct StateFileQuery {
path: String,
}
/// Resolve a caller-supplied path against the allow-listed roots
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
/// symlink + traversal checks before serving. Security model and
/// all five layers: `docs/security.md::State-file endpoint`.
fn resolve_state_path(
raw: &str,
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
use std::os::unix::fs::PermissionsExt as _;
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
let raw = raw.trim();
let (mapped, root): (std::path::PathBuf, &str) =
if let Some(rest) = raw.strip_prefix("/agents/") {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix("/shared/") {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) {
(
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
AGENTS_ROOT,
)
} else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) {
(
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
SHARED_ROOT,
)
} else {
return Err(format!("path not in allow-list: {raw}"));
};
reject_symlinks_below(std::path::Path::new(root), &mapped)?;
let canonical =
std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?;
if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) {
return Err(format!(
"resolved path escapes allow-list: {}",
canonical.display()
));
}
if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) {
let mut components = rel.components();
let _agent = components.next();
let dir = components.next().and_then(|c| c.as_os_str().to_str());
if dir != Some("state") {
return Err(format!(
"only per-agent state/ is readable here ({} dir not allowed)",
dir.unwrap_or("(root)")
));
}
}
let meta =
std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?;
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o004 == 0 {
return Err(format!(
"{} not world-readable (mode 0{:o}); refusing to proxy non-public file",
canonical.display(),
mode & 0o777,
));
}
}
Ok((canonical, meta))
}
/// Walk every path component under `root` and refuse if any of
/// them is a symlink. The roots themselves (`AGENTS_ROOT`,
/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only
/// the parts the agent / operator can plant matter. Components
/// that don't exist yet are skipped — `canonicalize` reports
/// non-existence separately, and missing-component checks would
/// just race the filesystem.
fn reject_symlinks_below(
root: &std::path::Path,
mapped: &std::path::Path,
) -> std::result::Result<(), String> {
let Ok(rel) = mapped.strip_prefix(root) else {
return Ok(());
};
let mut cumulative = root.to_path_buf();
for component in rel.components() {
match component {
std::path::Component::Normal(name) => {
cumulative.push(name);
match std::fs::symlink_metadata(&cumulative) {
Ok(m) if m.file_type().is_symlink() => {
return Err(format!(
"symlink at {} not allowed (canonicalize would resolve it past the \
allow-list check; refuse outright)",
cumulative.display()
));
}
Ok(_) | Err(_) => {}
}
}
std::path::Component::ParentDir => {
return Err(format!(
"path contains `..` traversal below {}; refuse outright",
root.display()
));
}
_ => {}
}
}
Ok(())
}
/// Scan `body` for path-shaped tokens and return those that pass the
/// allow-list + `is_file` check via `resolve_state_path`. Called at
/// broker-message ingest so the dashboard event already carries the
/// verified set; security rules stay in sync with the read endpoint.
pub fn scan_validated_paths(body: &str) -> Vec<String> {
const PREFIXES: [&str; 4] = [
"/agents/",
"/shared/",
"/var/lib/hyperhive/agents/",
"/var/lib/hyperhive/shared/",
];
let mut out = Vec::<String>::new();
for raw in body.split(|c: char| c.is_whitespace()) {
// Trim trailing natural-language punctuation that wouldn't
// be part of any real path. Inline rather than via a regex
// dep — the set is small and the call is hot.
let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']);
if token.is_empty() {
continue;
}
if !PREFIXES.iter().any(|p| token.starts_with(p)) {
continue;
}
// Cheap dedupe — typical message has 0-3 refs.
if out.iter().any(|s| s == token) {
continue;
}
if let Ok((_canonical, meta)) = resolve_state_path(token)
&& meta.is_file()
{
out.push(token.to_owned());
}
}
out
}
pub(super) async fn get_state_file(
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
) -> Response {
const MAX_BYTES: usize = 1 << 20; // 1 MiB
let (canonical, meta) = match resolve_state_path(&q.path) {
Ok(pair) => pair,
Err(e) => return error_response(&format!("state-file: {e}")),
};
if !meta.is_file() {
return error_response(&format!(
"state-file: {} is not a regular file",
canonical.display()
));
}
let size = meta.len();
let bytes = match std::fs::read(&canonical) {
Ok(b) => b,
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
};
// Raster images: serve the raw bytes with their real content-type
// so the dashboard can render them in an <img>. Not truncated —
// a clipped binary is corrupt, so over-cap images are rejected
// instead. (SVG stays on the text path: it's text, and the client
// renders it via a data: URI.)
if let Some(ct) = image_content_type(&canonical) {
if bytes.len() > MAX_BYTES {
return error_response(&format!(
"state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap",
canonical.display()
));
}
return ([("content-type", ct)], bytes).into_response();
}
let truncated = bytes.len() > MAX_BYTES;
let body_bytes = if truncated {
&bytes[..MAX_BYTES]
} else {
&bytes[..]
};
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
if truncated {
use std::fmt::Write as _;
let _ = write!(
body,
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
);
}
([("content-type", "text/plain; charset=utf-8")], body).into_response()
}
/// Content-type for a raster image the dashboard can preview in an
/// `<img>`, keyed off the file extension. `None` for non-image, SVG,
/// and text files (SVG is served on the text path and rendered
/// client-side via a `data:` URI).
fn image_content_type(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"avif" => "image/avif",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// Make a unique tmp subdir for the calling test. Caller is responsible
/// for cleanup (we leak on panic, fine for ephemeral CI runs).
fn tmproot(tag: &str) -> std::path::PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn reject_symlinks_below_accepts_plain_dirs_and_files() {
let root = tmproot("symlink-ok");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap();
assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok());
}
#[test]
fn reject_symlinks_below_rejects_leaf_symlink() {
let root = tmproot("symlink-leaf");
std::fs::create_dir_all(root.join("alice/state")).unwrap();
// Plant a symlink that points anywhere; resolve_state_path's
// canonicalize would happily resolve it past the allow-list
// check, so we have to refuse at the un-canonical layer.
symlink("/etc/shadow", root.join("alice/state/peek")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
assert!(err.contains("peek"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_directory_symlink_in_middle() {
let root = tmproot("symlink-mid");
std::fs::create_dir_all(root.join("real/state")).unwrap();
std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap();
// alice's "state" dir is actually a symlink to real/state — a
// sub-agent shouldn't be able to plant this and proxy real's
// private files via the dashboard.
std::fs::create_dir_all(root.join("alice")).unwrap();
symlink(root.join("real/state"), root.join("alice/state")).unwrap();
let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err();
assert!(err.contains("symlink at"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_rejects_parent_dir_traversal() {
let root = tmproot("symlink-dotdot");
// `..` doesn't survive canonicalize anyway, but we want a
// friendlier error than "path escapes allow-list" — refusing
// upfront also avoids walking ancestors with `symlink_metadata`.
let p = root.join("alice/state/../escape");
let err = reject_symlinks_below(&root, &p).unwrap_err();
assert!(err.contains("`..`"), "msg = {err}");
}
#[test]
fn reject_symlinks_below_passes_through_when_path_not_under_root() {
// resolve_state_path's earlier allow-list check would reject
// this; reject_symlinks_below stays a no-op so the caller
// surfaces the better-fit error.
let root = std::path::Path::new("/var/lib/hyperhive/agents");
assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok());
}
}