Swagger UI's endpoint-list row already shows the HTTP method badge + path for every row, so restating `METHOD /path` at the start of a handler's own summary is pure duplication. Strips that self-referential prefix from every summary that has it and re-capitalizes what follows as a standalone sentence. Left two false positives untouched: misc_api.rs's operator-inbox summary cross-references a *different* sibling endpoint (mark-all-read) for context, and topology.rs's SetParentForm struct doc happens to mention its endpoint's path but isn't a handler summary line. Both are legitimate, not redundant.
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
//! 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 utoipa::IntoParams;
|
|
|
|
use super::error_response;
|
|
use crate::paths::{AGENTS_ROOT, SHARED_ROOT};
|
|
|
|
#[derive(Deserialize, IntoParams)]
|
|
pub(super) struct StateFileQuery {
|
|
/// Absolute path under an agent's `state/` dir or under `shared/`;
|
|
/// checked against the allow-list (`docs/security.md::State-file
|
|
/// endpoint`).
|
|
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 _;
|
|
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> {
|
|
let agents_slash = format!("{AGENTS_ROOT}/");
|
|
let shared_slash = format!("{SHARED_ROOT}/");
|
|
let prefixes: [&str; 4] = ["/agents/", "/shared/", &agents_slash, &shared_slash];
|
|
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
|
|
}
|
|
|
|
/// Serve an allow-listed file.
|
|
///
|
|
/// Raster images get their real content-type; everything else is
|
|
/// served as (possibly truncated) text.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/state-file",
|
|
params(StateFileQuery),
|
|
responses(
|
|
(status = 200, description = "file contents (text, truncated at 1 MiB) or image bytes"),
|
|
(status = 500, description = "path outside the allow-list, not a regular file, or read failed"),
|
|
),
|
|
tag = "state_files"
|
|
)]
|
|
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());
|
|
}
|
|
}
|