path linkify: server-side validation via /api/state-file/check
regex back to permissive ("looks like a path") — the server is
authoritative on whether each match is a file. anchors render
optimistically, paths queue for batch validation (50ms coalesce),
non-files downgrade to plain text + the sibling <details>
preview is dropped. session-scoped cache (pathValidity Map) so
repeated paths skip the roundtrip.
new endpoint POST /api/state-file/check accepts { paths } and
returns { results: {<path>: bool} }. shares resolve_state_path
helper with the read endpoint so security rules can't drift —
both refuse anything outside the allow-list, anything resolved
outside via symlink, or anything in a per-agent subdir other
than state/. capped at 64 paths/request.
drops the brittle client-side filename heuristic (the .ext-
required rule that missed README/Makefile and still matched bare
dirs without trailing slash). single source of truth.
This commit is contained in:
parent
0e2d26304e
commit
6e098fad29
2 changed files with 145 additions and 41 deletions
|
|
@ -55,6 +55,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||
.route("/api/journal/{name}", get(get_journal))
|
||||
.route("/api/state-file", get(get_state_file))
|
||||
.route("/api/state-file/check", post(post_state_file_check))
|
||||
.route("/api/reminders", get(api_reminders))
|
||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||
.route("/api/agent-config/{name}", get(get_agent_config))
|
||||
|
|
@ -911,15 +912,17 @@ struct StateFileQuery {
|
|||
/// traversal and symlink games can't escape the roots. Files larger
|
||||
/// than `MAX_BYTES` are truncated with a banner so a runaway log
|
||||
/// can't OOM the browser.
|
||||
async fn get_state_file(
|
||||
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
|
||||
) -> Response {
|
||||
const MAX_BYTES: usize = 1 << 20; // 1 MiB
|
||||
/// Resolve a caller-supplied path string to a canonical host path
|
||||
/// that has been verified against the allow-list. Returns `Err`
|
||||
/// with a human-readable reason for every failure mode (path
|
||||
/// outside roots, canonicalize failure, escape via symlink,
|
||||
/// per-agent subdir not `state`). Shared by `get_state_file` (read)
|
||||
/// and `post_state_file_check` (existence probe) so both endpoints
|
||||
/// apply identical security rules.
|
||||
fn resolve_state_path(raw: &str) -> std::result::Result<std::path::PathBuf, String> {
|
||||
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
||||
let raw = q.path.trim();
|
||||
// Translate the container-view forms to host paths so the
|
||||
// allow-list check has a single canonical shape to match.
|
||||
let raw = raw.trim();
|
||||
let mapped: std::path::PathBuf = if let Some(rest) = raw.strip_prefix("/agents/") {
|
||||
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}"))
|
||||
} else if let Some(rest) = raw.strip_prefix("/shared/") {
|
||||
|
|
@ -927,38 +930,37 @@ async fn get_state_file(
|
|||
} else if raw.starts_with(AGENTS_ROOT) || raw.starts_with(SHARED_ROOT) {
|
||||
std::path::PathBuf::from(raw)
|
||||
} else {
|
||||
return error_response(&format!("state-file: path not in allow-list: {raw}"));
|
||||
return Err(format!("path not in allow-list: {raw}"));
|
||||
};
|
||||
// Canonicalise so `..` / symlinks resolve before the prefix
|
||||
// check. A failure here means the path doesn't exist on disk
|
||||
// (or we can't reach it) — surface the underlying error.
|
||||
let canonical = match std::fs::canonicalize(&mapped) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return error_response(&format!("state-file: {}: {e}", mapped.display())),
|
||||
};
|
||||
let allowed = canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT);
|
||||
if !allowed {
|
||||
return error_response(&format!(
|
||||
"state-file: resolved path escapes allow-list: {}",
|
||||
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()
|
||||
));
|
||||
}
|
||||
// For per-agent paths, also require the second-from-root
|
||||
// component to be `state` (not `claude` or `config`). Claude
|
||||
// creds shouldn't leak through this endpoint; config is the
|
||||
// applied repo (already exposed via /api/agent-config). Reading
|
||||
// `/var/lib/hyperhive/agents/<n>/state/...` is the intended use.
|
||||
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 error_response(&format!(
|
||||
"state-file: only per-agent state/ is readable here ({} dir not allowed)",
|
||||
return Err(format!(
|
||||
"only per-agent state/ is readable here ({} dir not allowed)",
|
||||
dir.unwrap_or("(root)")
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
async fn get_state_file(
|
||||
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
|
||||
) -> Response {
|
||||
const MAX_BYTES: usize = 1 << 20; // 1 MiB
|
||||
let canonical = match resolve_state_path(&q.path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return error_response(&format!("state-file: {e}")),
|
||||
};
|
||||
let meta = match std::fs::metadata(&canonical) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return error_response(&format!("state-file: stat {}: {e}", canonical.display())),
|
||||
|
|
@ -985,6 +987,36 @@ async fn get_state_file(
|
|||
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StateFileCheckReq {
|
||||
paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Batch existence/file-ness probe behind the path-link autodetect.
|
||||
/// The client collects regex-candidate paths from a message body,
|
||||
/// fires one POST with the whole batch, and downgrades anchors
|
||||
/// whose result is `false` back to plain text. Same security rules
|
||||
/// as `get_state_file` (via `resolve_state_path`); a path is `true`
|
||||
/// iff it resolves, lives in the allow-list, and is a regular file
|
||||
/// (not a dir, symlink-to-dir, missing file, or forbidden subtree).
|
||||
/// Capped per-request to keep a runaway message body from
|
||||
/// triggering thousands of canonicalize calls in one request.
|
||||
async fn post_state_file_check(
|
||||
axum::Json(req): axum::Json<StateFileCheckReq>,
|
||||
) -> Response {
|
||||
const MAX_PATHS: usize = 64;
|
||||
let mut out: std::collections::HashMap<String, bool> =
|
||||
std::collections::HashMap::with_capacity(req.paths.len().min(MAX_PATHS));
|
||||
for raw in req.paths.into_iter().take(MAX_PATHS) {
|
||||
let is_file = match resolve_state_path(&raw) {
|
||||
Ok(p) => std::fs::metadata(&p).is_ok_and(|m| m.is_file()),
|
||||
Err(_) => false,
|
||||
};
|
||||
out.insert(raw, is_file);
|
||||
}
|
||||
axum::Json(serde_json::json!({ "results": out })).into_response()
|
||||
}
|
||||
|
||||
async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||
match state.coord.broker.list_pending_reminders() {
|
||||
Ok(rows) => axum::Json(rows).into_response(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue