mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
6
Justfile
6
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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user