diff --git a/docs/security.md b/docs/security.md index 831bed24..d740f066 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,5 +1,42 @@ # Security model +## State-file endpoint security model + +`GET /api/state-file?path=

` serves files from agent state dirs and +the shared space to authenticated dashboard users (browser, operator). +Two allow-listed root prefixes are accepted; all other paths are rejected +before touching the filesystem: + +- `/var/lib/hyperhive/agents//state/` — per-agent durable notes + (canonical host form or the in-container view `/agents//state/`) +- `/var/lib/hyperhive/shared/` — shared docs (`/shared/` in-container) + +`/state/...` without an agent prefix is explicitly *not* accepted — it is +ambiguous from the host's perspective. + +Defense-in-depth layers (in order): + +1. **Allow-list prefix check** — rejects without touching the filesystem + if the path doesn't match either root. +2. **No symlinks below the matched root** — each path component is + checked with `symlink_metadata` before canonicalize. A sub-agent + that plants `ln -s /other/secret /agents/me/state/peek` can't proxy + another agent's file through this endpoint (canonicalize would + happily resolve the symlink to a still-within-allow-list path). +3. **Canonicalize as belt-and-braces** — resolves `..`/`.` traversal + and rejects if the result escapes the roots. +4. **`state/` subdir constraint** — under `AGENTS_ROOT`, the second + path component must be `state/`. Applied, proposed git repos and + config dirs are off-limits. +5. **World-readable check** — file must have `mode & 0o004` set. + A `0600` file inside `state/` would otherwise be accessible to any + operator with dashboard access. + +`scan_validated_paths` (broker-message ingest, linkifier) uses the same +`resolve_state_path` helper so security rules stay in sync — the +dashboard renders anchors only for tokens that passed the same checks the +read endpoint enforces. + ## Nix builds and credential isolation ### Background diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 15794ef6..de780ea1 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -94,13 +94,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { // /static/dashboard.css → dist/static/dashboard.css, etc.). .fallback_service(ServeDir::new(&static_dir)) .with_state(AppState { coord }); - // Bind loopback-only. External access funnels through - // hive-gateway (in-host-netns nginx container), which proxies - // `/` → `127.0.0.1:` upstream. Operators who opt - // out of the gateway lose remote dashboard access — that's by - // design; the c0re HTTP surface is privileged (approve / deny / - // destroy, etc.) and any external exposure needs to pass through - // a real reverse proxy with auth. + // Binds loopback-only; external access via gateway. + // Rationale: docs/gateway.md::Firewall posture. let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = bind_with_retry(addr).await?; tracing::info!(%addr, "dashboard listening"); @@ -108,27 +103,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon -// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the -// hive-c0re NixOS module to `${frontend}/dashboard`), served by the -// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic -// surface — `/api/state` and the action endpoints — is owned here. -// The JS app fetches state on load, re-fetches after every async-form -// submit, and listens on `/dashboard/stream` for the unified live event -// channel. -// --------------------------------------------------------------------------- +// SPA shape + SSE channels: docs/web-ui/shape.md. -/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in -/// `hive-ag3nt::web_ui::bind_with_retry`: hive-c0re restarts also -/// race the previous process's socket release, and the retry has no -/// attempt cap — capping was the proximate cause of a silent -/// give-up on a long stale socket. Genuine port collisions -/// don't reach this layer (dashboard is bound to a fixed configured -/// port, no per-agent hashing), so any persistent `AddrInUse` always -/// reflects a recoverable stale socket. WARN for the first dozen -/// attempts; INFO after that to avoid spamming the journal during a -/// long hold; INFO on eventual success when we did have to retry. +/// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap +/// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`. async fn bind_with_retry(addr: SocketAddr) -> Result { let mut delay_ms = 250u64; let mut attempts = 0u32; @@ -1211,54 +1189,10 @@ struct StateFileQuery { path: String, } -/// Bounded-size read of a file under one of two allow-listed -/// roots: `/var/lib/hyperhive/agents//state/` (per-agent durable -/// notes — the only writable path agents have outside their -/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both -/// path forms are accepted: -/// - canonical host: `/var/lib/hyperhive/agents/alice/state/foo.md` -/// - container view: `/agents/alice/state/foo.md` -/// - shared: `/shared/foo.md` -/// -/// `/state/...` on its own is *not* accepted — the in-container -/// mount is ambiguous from the host's perspective (we don't know -/// which agent's `/state` it refers to) and using it would silently -/// resolve to the wrong file. -/// -/// Path is canonicalised before the allow-list check so `..` -/// 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. -/// 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`, symlink anywhere below the root, -/// file not world-readable). Shared by `get_state_file` (read) and -/// `scan_validated_paths` (linkify candidates in message bodies) -/// so both apply identical security rules and the linkifier -/// doesn't render a path the reader will refuse to serve. -/// -/// Defense-in-depth layers (in order): -/// 1. Caller-supplied prefix has to match the allow-list (agents/ -/// or shared/), else reject without touching the fs. -/// 2. No symlinks below the matched root. Walked pre-canonicalize -/// via `symlink_metadata` on each component so a sub-agent that -/// plants `ln -s /var/lib/hyperhive/agents/other/state/secret -/// /agents/me/state/peek` can't proxy a different agent's file -/// through this endpoint (canonicalize would happily resolve -/// the symlink to a path inside the allow-list). -/// 3. Canonicalize is run anyway as a belt-and-braces check — -/// resolves `..`/`.` traversal and rejects if the result -/// escapes the roots. -/// 4. Under `AGENTS_ROOT`, the second path component must be -/// `state/` — agents' applied/proposed git repos and config dirs -/// are off-limits. -/// 5. The target's metadata is fetched once and returned to the -/// caller so they don't restat. If the target is a regular -/// file it must be world-readable (mode & 0o004); a 0600 file -/// inside `state/` could leak through this endpoint to anyone -/// holding the dashboard URL otherwise. +/// Resolve a caller-supplied path against the allow-listed roots +/// (`agents//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> { @@ -1571,21 +1505,10 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) { }); } -/// Scan `body` for path-shaped tokens, validate each against the -/// allow-list, return the unique set of tokens that resolve to a -/// regular file. Called at broker-message ingest time so the -/// dashboard event already carries the verified set — no client- -/// side probe endpoint required, and historical messages get the -/// same treatment on `/dashboard/history` backfill. -/// -/// Tokenisation: split on whitespace + a handful of trailing -/// punctuation chars (`,;:)]}`) that commonly follow paths in -/// natural-language text but aren't part of the path itself. Any -/// token starting with `/agents/`, `/shared/`, or -/// `/var/lib/hyperhive/{agents,shared}/` is a candidate. The -/// allow-list + `is_file` check happens via the same -/// `resolve_state_path` helper the read endpoint uses, so the -/// security rules can't drift. +/// 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 { const PREFIXES: [&str; 4] = [ "/agents/",