docs: move dashboard.rs security + bind rationale to docs (#715)
This commit is contained in:
parent
12a26e6cb0
commit
42fe3965de
2 changed files with 51 additions and 91 deletions
|
|
@ -1,5 +1,42 @@
|
|||
# Security model
|
||||
|
||||
## State-file endpoint security model
|
||||
|
||||
`GET /api/state-file?path=<p>` 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/<n>/state/` — per-agent durable notes
|
||||
(canonical host form or the in-container view `/agents/<n>/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
|
||||
|
|
|
|||
|
|
@ -94,13 +94,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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:<dashboardPort>` 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<Coordinator>) -> 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<tokio::net::TcpListener> {
|
||||
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/<n>/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/<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> {
|
||||
|
|
@ -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<String> {
|
||||
const PREFIXES: [&str; 4] = [
|
||||
"/agents/",
|
||||
|
|
|
|||
Loading…
Reference in a new issue