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
|
# 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
|
## Nix builds and credential isolation
|
||||||
|
|
||||||
### Background
|
### Background
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
// Bind loopback-only. External access funnels through
|
// Binds loopback-only; external access via gateway.
|
||||||
// hive-gateway (in-host-netns nginx container), which proxies
|
// Rationale: docs/gateway.md::Firewall posture.
|
||||||
// `/` → `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.
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
let listener = bind_with_retry(addr).await?;
|
let listener = bind_with_retry(addr).await?;
|
||||||
tracing::info!(%addr, "dashboard listening");
|
tracing::info!(%addr, "dashboard listening");
|
||||||
|
|
@ -108,27 +103,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// SPA shape + SSE channels: docs/web-ui/shape.md.
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
|
/// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap
|
||||||
/// `hive-ag3nt::web_ui::bind_with_retry`: hive-c0re restarts also
|
/// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`.
|
||||||
/// 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.
|
|
||||||
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
|
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
|
||||||
let mut delay_ms = 250u64;
|
let mut delay_ms = 250u64;
|
||||||
let mut attempts = 0u32;
|
let mut attempts = 0u32;
|
||||||
|
|
@ -1211,54 +1189,10 @@ struct StateFileQuery {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded-size read of a file under one of two allow-listed
|
/// Resolve a caller-supplied path against the allow-listed roots
|
||||||
/// roots: `/var/lib/hyperhive/agents/<n>/state/` (per-agent durable
|
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
|
||||||
/// notes — the only writable path agents have outside their
|
/// symlink + traversal checks before serving. Security model and
|
||||||
/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both
|
/// all five layers: `docs/security.md::State-file endpoint`.
|
||||||
/// 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.
|
|
||||||
fn resolve_state_path(
|
fn resolve_state_path(
|
||||||
raw: &str,
|
raw: &str,
|
||||||
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
|
) -> 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
|
/// Scan `body` for path-shaped tokens and return those that pass the
|
||||||
/// allow-list, return the unique set of tokens that resolve to a
|
/// allow-list + `is_file` check via `resolve_state_path`. Called at
|
||||||
/// regular file. Called at broker-message ingest time so the
|
/// broker-message ingest so the dashboard event already carries the
|
||||||
/// dashboard event already carries the verified set — no client-
|
/// verified set; security rules stay in sync with the read endpoint.
|
||||||
/// 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.
|
|
||||||
pub fn scan_validated_paths(body: &str) -> Vec<String> {
|
pub fn scan_validated_paths(body: &str) -> Vec<String> {
|
||||||
const PREFIXES: [&str; 4] = [
|
const PREFIXES: [&str; 4] = [
|
||||||
"/agents/",
|
"/agents/",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue