refactor(#1456): extract dashboard state-file proxy + path-validation into dashboard/state_files.rs
This commit is contained in:
parent
ec3ca216c5
commit
df3058e311
2 changed files with 314 additions and 288 deletions
|
|
@ -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
|
||||
|
|
|
|||
308
hive-c0re/src/dashboard/state_files.rs
Normal file
308
hive-c0re/src/dashboard/state_files.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue