diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 2288396d..eda66f71 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1263,6 +1263,36 @@ mod tests { p } + #[test] + fn validate_agent_name_accepts_canonical_shapes() { + assert!(validate_agent_name("damocles").is_none()); + assert!(validate_agent_name("hm1nd").is_none()); + assert!(validate_agent_name("agent-with-dashes").is_none()); + assert!(validate_agent_name("snake_case").is_none()); + assert!(validate_agent_name("mixed_2-3").is_none()); + let max = "a".repeat(63); + assert!(validate_agent_name(&max).is_none(), "63-char name should pass"); + } + + #[test] + fn validate_agent_name_rejects_bad_input() { + assert!(validate_agent_name("").is_some()); + let too_long = "a".repeat(64); + assert!(validate_agent_name(&too_long).is_some()); + // Path-traversal attempts. + assert!(validate_agent_name("../etc/passwd").is_some()); + assert!(validate_agent_name("alice/bob").is_some()); + // Uppercase rejected — canonical lowercase convention. + assert!(validate_agent_name("Alice").is_some()); + // No spaces, dots, special chars. + assert!(validate_agent_name("alice bob").is_some()); + assert!(validate_agent_name("alice.bob").is_some()); + assert!(validate_agent_name("alice;DROP TABLE messages").is_some()); + // Non-ASCII (incl. unicode homoglyphs of ASCII dash). + assert!(validate_agent_name("damóclès").is_some()); + assert!(validate_agent_name("alice\u{2013}bob").is_some()); // en-dash + } + #[test] fn reject_symlinks_below_accepts_plain_dirs_and_files() { let root = tmproot("symlink-ok"); @@ -1748,6 +1778,31 @@ async fn post_retry_reminder( } } +/// Validate that a path-param agent name conforms to the hyperhive +/// naming whitelist: 1-63 chars of `[a-z0-9_-]`. Rejects empty, +/// uppercase, slashes, dots, and any non-ASCII (incl. unicode +/// homoglyphs of dash/underscore). Returns `None` on accept, `Some(reason)` +/// on reject — caller wraps the reason in a 400 response. Conservative +/// whitelist matching `nixos-container` basename rules and the existing +/// agent-name convention across the codebase. (mara nag on #566 — this +/// is the local fix for `mark-all-read`; general extractor + rollout +/// across other endpoints tracked at #572.) +fn validate_agent_name(name: &str) -> Option<&'static str> { + if name.is_empty() { + return Some("agent name must not be empty"); + } + if name.len() > 63 { + return Some("agent name must be 63 characters or fewer"); + } + if !name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') + { + return Some("agent name must contain only [a-z0-9_-]"); + } + None +} + /// Operator-driven "clear this agent's inbox" — backs the side-panel /// "mark all read" button (#559). Marks every message addressed to the /// agent as acked (backfilling `delivered_at` for any still-pending @@ -1758,6 +1813,9 @@ async fn post_mark_all_read( State(state): State, AxumPath(name): AxumPath, ) -> Response { + if let Some(reason) = validate_agent_name(&name) { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); + } match state.coord.broker.mark_all_read(&name) { Ok(n) => { tracing::info!(%name, marked = n, "operator marked all messages read");