Require credentials for deployment admin access

Co-authored-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-07-31 14:26:15 -07:00
parent 5c98932c59
commit b747feddb3
7 changed files with 242 additions and 16 deletions

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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
}
}

View File

@@ -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<crate::state::AppState>) -> 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"),
)

View File

@@ -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<std::path::PathBuf>,
}
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();

View File

@@ -161,6 +161,9 @@ pub fn build_router(state: Arc<AppState>) -> 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();
}

View File

@@ -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=<random password with at least 16 bytes>
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.