From b747feddb3df4948d2ba6f3b7f0b96410c579e08 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 31 Jul 2026 14:26:15 -0700 Subject: [PATCH] Require credentials for deployment admin access Co-authored-by: Jordan Mecom Signed-off-by: Jordan Mecom --- Justfile | 6 ++ crates/buzz-relay/src/api/admin/auth.rs | 46 ++++++++++- crates/buzz-relay/src/api/admin/error.rs | 27 +++++- crates/buzz-relay/src/api/admin/mod.rs | 45 +++++++++- crates/buzz-relay/src/config.rs | 101 ++++++++++++++++++++++- crates/buzz-relay/src/router.rs | 6 ++ docs/admin/README.md | 27 +++--- 7 files changed, 242 insertions(+), 16 deletions(-) diff --git a/Justfile b/Justfile index d6e86c8d09..71c612b43d 100644 --- a/Justfile +++ b/Justfile @@ -403,8 +403,14 @@ admin: bootstrap _ensure-migrations [[ -d node_modules ]] || pnpm install pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" + export BUZZ_ADMIN_USERNAME="${BUZZ_ADMIN_USERNAME:-admin}" + if [[ -z "${BUZZ_ADMIN_PASSWORD:-}" ]]; then + export BUZZ_ADMIN_PASSWORD="$(openssl rand -hex 16)" + fi export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}" echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports" + echo "Admin username: ${BUZZ_ADMIN_USERNAME}" + echo "Admin password: ${BUZZ_ADMIN_PASSWORD}" cargo run -p buzz-relay # Seed deterministic reports and product feedback for local admin dashboard review diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs index 71b8c9a8a5..2f9ac8a7da 100644 --- a/crates/buzz-relay/src/api/admin/auth.rs +++ b/crates/buzz-relay/src/api/admin/auth.rs @@ -1,4 +1,7 @@ use axum::http::{header, HeaderMap}; +use base64::Engine as _; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use super::error::ApiError; use crate::state::AppState; @@ -29,9 +32,32 @@ pub fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> }) { return Err(ApiError::forbidden()); } + if !has_valid_credentials(headers, &config.credential_digest) { + return Err(ApiError::unauthorized()); + } Ok(()) } +fn has_valid_credentials(headers: &HeaderMap, expected_digest: &[u8; 32]) -> bool { + let Some(value) = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let Some((scheme, encoded)) = value.split_once(' ') else { + return false; + }; + if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() { + return false; + } + let Ok(actual) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + return false; + }; + let actual_digest: [u8; 32] = Sha256::digest(&actual).into(); + bool::from(actual_digest.ct_eq(expected_digest)) +} + fn origin_matches_host(origin: &str, host: &str) -> bool { origin .strip_prefix("https://") @@ -41,7 +67,9 @@ fn origin_matches_host(origin: &str, host: &str) -> bool { #[cfg(test)] mod tests { - use super::origin_matches_host; + use super::{has_valid_credentials, origin_matches_host}; + use axum::http::{header, HeaderMap, HeaderValue}; + use base64::Engine as _; #[test] fn browser_origin_must_match_admin_host() { @@ -59,4 +87,20 @@ mod tests { )); assert!(!origin_matches_host("null", "admin.example.com")); } + + #[test] + fn admin_credentials_are_required_and_compared_exactly() { + let encoded = base64::engine::general_purpose::STANDARD.encode("operator:correct secret"); + let expected = crate::config::AdminConfig::credential_digest("operator", "correct secret"); + let mut headers = HeaderMap::new(); + assert!(!has_valid_credentials(&headers, &expected)); + + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Basic {encoded}")).expect("valid header"), + ); + assert!(has_valid_credentials(&headers, &expected)); + let wrong = crate::config::AdminConfig::credential_digest("operator", "wrong secret"); + assert!(!has_valid_credentials(&headers, &wrong)); + } } diff --git a/crates/buzz-relay/src/api/admin/error.rs b/crates/buzz-relay/src/api/admin/error.rs index 02190384f5..458d072237 100644 --- a/crates/buzz-relay/src/api/admin/error.rs +++ b/crates/buzz-relay/src/api/admin/error.rs @@ -10,6 +10,7 @@ pub struct ApiError { pub status: StatusCode, pub code: &'static str, pub message: &'static str, + challenge_basic: bool, } #[derive(Serialize)] @@ -31,6 +32,16 @@ impl ApiError { status: StatusCode::BAD_REQUEST, code, message, + challenge_basic: false, + } + } + + pub fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code: "unauthorized", + message: "admin credentials are required", + challenge_basic: true, } } @@ -39,6 +50,7 @@ impl ApiError { status: StatusCode::FORBIDDEN, code: "forbidden", message: "request is not authorized", + challenge_basic: false, } } @@ -47,6 +59,7 @@ impl ApiError { status: StatusCode::NOT_FOUND, code: "not_found", message: "record was not found", + challenge_basic: false, } } @@ -55,13 +68,14 @@ impl ApiError { status: StatusCode::INTERNAL_SERVER_ERROR, code: "internal_error", message: "request failed", + challenge_basic: false, } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { - ( + let mut response = ( self.status, Json(ErrorEnvelope { error: ErrorBody { @@ -71,7 +85,16 @@ impl IntoResponse for ApiError { }, }), ) - .into_response() + .into_response(); + if self.challenge_basic { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderValue::from_static( + "Basic realm=\"buzz-admin\", charset=\"UTF-8\"", + ), + ); + } + response } } diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0..ff32e13f91 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -10,7 +10,7 @@ use axum::{ extract::{Path, Query, State}, http::{header, HeaderMap, HeaderValue}, middleware::{self, Next}, - response::Response, + response::{IntoResponse, Response}, routing::get, Json, Router, }; @@ -24,6 +24,13 @@ pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap) auth::is_admin_host(state, headers) } +pub(crate) fn authorize_request( + state: &crate::state::AppState, + headers: &HeaderMap, +) -> Result<(), Response> { + authorize(state, headers).map_err(|error| error.into_response()) +} + /// Build the read-only deployment-admin routes. pub fn router(state: Arc) -> Router { Router::new() @@ -337,6 +344,11 @@ mod tests { config.redis_url = "redis://127.0.0.1:1".to_string(); config.admin = Some(crate::config::AdminConfig { host: "admin.example".to_string(), + username: "operator".to_string(), + credential_digest: crate::config::AdminConfig::credential_digest( + "operator", + "correct horse battery staple", + ), web_dir: None, }); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); @@ -374,6 +386,13 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + fn admin_authorization() -> String { + use base64::Engine as _; + let encoded = base64::engine::general_purpose::STANDARD + .encode("operator:correct horse battery staple"); + format!("Basic {encoded}") + } + #[tokio::test] async fn report_detail_requires_admin_host_before_database_access() { let response = router(test_state().await) @@ -396,6 +415,7 @@ mod tests { Request::builder() .uri(format!("/reports/{}", Uuid::nil())) .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, admin_authorization()) .body(Body::empty()) .expect("request"), ) @@ -404,6 +424,28 @@ mod tests { assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); } + #[tokio::test] + async fn report_detail_requires_admin_credentials_before_database_access() { + let response = router(test_state().await) + .oneshot( + Request::builder() + .uri(format!("/reports/{}", Uuid::nil())) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!( + response + .headers() + .get(header::WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()), + Some("Basic realm=\"buzz-admin\", charset=\"UTF-8\"") + ); + } + #[tokio::test] async fn feedback_attachment_requires_admin_host_before_database_access() { let response = router(test_state().await) @@ -426,6 +468,7 @@ mod tests { Request::builder() .uri(format!("/feedback/{}/attachments/{HASH}", Uuid::nil())) .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, admin_authorization()) .body(Body::empty()) .expect("request"), ) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..b19b7d60f1 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -25,14 +25,36 @@ pub enum ConfigError { } /// Deny-by-default read-only deployment-admin configuration. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AdminConfig { /// Exact admin HTTP authority. pub host: String, + /// HTTP Basic username presented by deployment administrators. + pub username: String, + /// SHA-256 of the expected `username:password` bytes. + pub credential_digest: [u8; 32], /// Optional admin SPA bundle directory. pub web_dir: Option, } +impl std::fmt::Debug for AdminConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AdminConfig") + .field("host", &self.host) + .field("username", &self.username) + .field("credential_digest", &"[REDACTED]") + .field("web_dir", &self.web_dir) + .finish() + } +} + +impl AdminConfig { + pub(crate) fn credential_digest(username: &str, password: &str) -> [u8; 32] { + Sha256::digest(format!("{username}:{password}").as_bytes()).into() + } +} + /// Relay-hosted policy content presented on join surfaces. #[derive(Debug, Clone)] pub struct JoinPolicyConfig { @@ -883,6 +905,36 @@ impl Config { "BUZZ_ADMIN_HOST must be an exact authority".to_string(), )); } + let username = std::env::var("BUZZ_ADMIN_USERNAME") + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "admin".to_string()); + if username.len() > 128 + || username.contains(':') + || username.chars().any(char::is_control) + { + return Err(ConfigError::InvalidValue( + "BUZZ_ADMIN_USERNAME must be at most 128 characters and contain no colon or control characters" + .to_string(), + )); + } + let password = std::env::var("BUZZ_ADMIN_PASSWORD") + .ok() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_ADMIN_PASSWORD is required when BUZZ_ADMIN_HOST is set" + .to_string(), + ) + })?; + if !(16..=1024).contains(&password.len()) || password.chars().any(char::is_control) + { + return Err(ConfigError::InvalidValue( + "BUZZ_ADMIN_PASSWORD must be 16 to 1024 bytes and contain no control characters" + .to_string(), + )); + } let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR") .ok() .map(|value| std::path::PathBuf::from(value.trim())) @@ -895,7 +947,12 @@ impl Config { ))); } } - Some(AdminConfig { host, web_dir }) + Some(AdminConfig { + credential_digest: AdminConfig::credential_digest(&username, &password), + host, + username, + web_dir, + }) } }; @@ -1053,6 +1110,46 @@ mod tests { ); } + #[test] + fn admin_host_requires_a_strong_credential() { + let _guard = ENV_MUTEX.lock().unwrap(); + const NAMES: [&str; 4] = [ + "BUZZ_ADMIN_HOST", + "BUZZ_ADMIN_USERNAME", + "BUZZ_ADMIN_PASSWORD", + "BUZZ_ADMIN_WEB_DIR", + ]; + let previous = NAMES.map(|name| std::env::var_os(name)); + for name in NAMES { + std::env::remove_var(name); + } + + std::env::set_var("BUZZ_ADMIN_HOST", "admin.example"); + let missing = Config::from_env().expect_err("password must be required"); + assert!(missing + .to_string() + .contains("BUZZ_ADMIN_PASSWORD is required")); + + std::env::set_var("BUZZ_ADMIN_PASSWORD", "too-short"); + let short = Config::from_env().expect_err("short password must be rejected"); + assert!(short.to_string().contains("16 to 1024 bytes")); + + std::env::set_var("BUZZ_ADMIN_USERNAME", "operator"); + std::env::set_var("BUZZ_ADMIN_PASSWORD", "correct horse battery staple"); + let config = Config::from_env().expect("valid admin credential"); + let admin = config.admin.expect("admin config"); + assert_eq!(admin.username, "operator"); + assert!(!format!("{admin:?}").contains("correct horse battery staple")); + + for (name, value) in NAMES.into_iter().zip(previous) { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + } + #[test] fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..0926da51fe 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -161,6 +161,9 @@ pub fn build_router(state: Arc) -> Router { let path = req.uri().path(); let admin_host = api::admin::is_admin_host(&state, req.headers()); if admin_host { + if let Err(response) = api::admin::authorize_request(&state, req.headers()) { + return Ok(response); + } if let (Some(index), Some(files)) = (admin_index, admin_files) { if path.starts_with("/assets/") { return files.oneshot(req).await.map(IntoResponse::into_response); @@ -271,6 +274,9 @@ async fn nip11_or_ws_handler( // Short-circuit the exact admin authority here and never let it serve the // public web bundle, NIP-11 document, or WebSocket endpoint. if api::admin::is_admin_host(&state, &headers) { + if let Err(response) = api::admin::authorize_request(&state, &headers) { + return response; + } if !accept.contains("text/html") { return StatusCode::NOT_FOUND.into_response(); } diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..38df59e732 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -3,19 +3,26 @@ Buzz can expose a private, deployment-wide read-only dashboard from the existing relay process. It shows open moderation reports and recent product feedback. -Configure `BUZZ_ADMIN_HOST` to activate the dashboard. A private ingress limits -access to the operator VPN or approved source IPs. +Configure `BUZZ_ADMIN_HOST` and a strong password to activate the dashboard. A +private ingress should also limit access to the operator VPN or approved source +IPs. Required configuration: ```text BUZZ_ADMIN_HOST=admin.example.com +BUZZ_ADMIN_USERNAME=admin +BUZZ_ADMIN_PASSWORD= BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web ``` -The relay requires the configured admin host and matching browser origin. -Requests and responses are bounded and uncached. The deployment routes admin -traffic through the private ingress. +The relay requires HTTP Basic credentials before it serves the dashboard, its +assets, any admin API, or an attachment. `BUZZ_ADMIN_USERNAME` defaults to +`admin`; the password has no default, +must be at least 16 bytes, and is retained by the relay only as a digest. The +configured host and matching browser origin remain defense-in-depth checks. +Requests and responses are bounded and uncached. Use HTTPS so the credentials +are not exposed in transit, and route admin traffic through the private ingress. When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while preserving the admin `Host` header. A `NetworkPolicy` grants the admin pod access @@ -62,8 +69,8 @@ content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback body or attachment URL. -The human trust boundary remains the private admin ingress. VPN/source-IP -admission is not per-operator identity. Anyone admitted to the dashboard can -read attachments for feedback records they can access. Per-person attribution -or revocation requires authenticated operator identity at ingress/application -level; this endpoint deliberately does not claim to provide it. +The deployment credential identifies administrators as a group, not as +individual people. Anyone with that credential can read attachments for +feedback records they can access. Rotate the password when an administrator +loses access. Per-person attribution requires identity-aware authentication at +the ingress or application layer.