diff options
| author | tv <tv@krebsco.de> | 2026-10-24 19:25:54 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 19:25:54 +0200 |
| commit | 815ec2e9a641ad5ac5c2f886291e63c89966bd6c (patch) | |
| tree | 5840ab9e9adc8e5ab5259ef0da7949880cde5f53 /src | |
| parent | 09f2dfd083c4fcdce4c5ca7376353906eb025c42 (diff) | |
only protocol-writers may modify _continuum
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/auth.rs | 25 | ||||
| -rw-r--r-- | src/api/mod.rs | 27 | ||||
| -rw-r--r-- | src/api/store.rs | 12 | ||||
| -rw-r--r-- | src/config/mod.rs | 9 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/state.rs | 3 |
6 files changed, 63 insertions, 14 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs index 70b1af5..de4220c 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -72,14 +72,18 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { Some((user.to_owned(), pass.to_owned())) } -async fn authenticate(state: &AuthState, headers: &HeaderMap) -> bool { - let Some((user, pass)) = parse_basic(headers) else { - return false; - }; - let Some(hash) = state.users.get(&user).cloned() else { - return false; - }; +/// The user a request authenticated as (a request extension, set by [`basic_auth`]). +#[derive(Clone, Debug)] +pub(crate) struct AuthenticatedUser(pub(crate) String); +/// The user, if the credentials are valid. +async fn authenticate(state: &AuthState, headers: &HeaderMap) -> Option<String> { + let (user, pass) = parse_basic(headers)?; + let hash = state.users.get(&user).cloned()?; + verify(state, &user, pass, hash).await.then_some(user) +} + +async fn verify(state: &AuthState, user: &str, pass: String, hash: String) -> bool { let key = *blake3::hash(format!("{user}\0{pass}\0{hash}").as_bytes()).as_bytes(); // Fast path. The shard guard lives only until the end of this `match`; @@ -124,10 +128,10 @@ async fn authenticate(state: &AuthState, headers: &HeaderMap) -> bool { pub async fn basic_auth( State(state): State<AuthState>, headers: HeaderMap, - request: Request<Body>, + mut request: Request<Body>, next: axum::middleware::Next, ) -> Response { - if !authenticate(&state, &headers).await { + let Some(user) = authenticate(&state, &headers).await else { sleep(Duration::from_millis(1500)).await; return ( StatusCode::UNAUTHORIZED, @@ -135,7 +139,8 @@ pub async fn basic_auth( "Unauthorized", ) .into_response(); - } + }; + request.extensions_mut().insert(AuthenticatedUser(user)); next.run(request).await } diff --git a/src/api/mod.rs b/src/api/mod.rs index ce1f6ae..1fba48b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -24,7 +24,7 @@ use tracing::{error, warn}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::api::auth::{AuthState, basic_auth}; +use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth}; use crate::document::store::StoreError; use crate::state::AppState; @@ -104,6 +104,31 @@ fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> { Ok(segments) } +/// A path for a write. Protocol data (`_continuum`: remake records, members, forget +/// records, epoch metadata) is what every node acts on, so a write there acts on the +/// whole swarm: only users listed in `--protocol-writers` may, and each such write is +/// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never +/// writable: the protocol reads it at the snapshot change, so an edit only misleads +/// whoever reads it later. `403` otherwise. +fn parse_write_path( + state: &AppState, + user: &AuthenticatedUser, + key: &str, + method: &str, +) -> Result<Vec<String>, StatusCode> { + let path = parse_key_path(key)?; + if path.first().map(String::as_str) != Some(crate::remake::snapshot::META_ROOT) { + return Ok(path); + } + let epochs = path.len() < 2 || path[1] == crate::remake::snapshot::EPOCHS; + if epochs || !state.protocol_writers.contains(&user.0) { + warn!(user = %user.0, method, path = %key, "Refused a write to protocol data"); + return Err(StatusCode::FORBIDDEN); + } + warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm"); + Ok(path) +} + fn response_for_value( value: &JsonValue, accept: Option<&HeaderValue>, diff --git a/src/api/store.rs b/src/api/store.rs index d6ff306..aa9465c 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -4,6 +4,7 @@ use std::io::Error as IoError; use automerge::ChangeHash; use axum::{ + Extension, body::{Body, Bytes}, extract::{Path, Query, State}, http::{ @@ -17,7 +18,8 @@ use serde_json::Value as JsonValue; use tokio::sync::broadcast; use tracing::{debug, error, instrument}; -use super::{parse_key_path, response_for_value, store_status}; +use super::auth::AuthenticatedUser; +use super::{parse_key_path, parse_write_path, response_for_value, store_status}; use crate::document::store::{self, StoreError}; use crate::state::{AppState, ReadSource}; use crate::swarm::sync::paths_related; @@ -194,6 +196,7 @@ fn mark_source( responses( (status = 200, description = "Value written successfully"), (status = 400, description = "Invalid path, malformed JSON, invalid UTF-8, or unsupported JSON value"), + (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"), (status = 409, description = "Path conflicts with an existing non-map value"), (status = 415, description = "Unsupported Content-Type"), (status = 500, description = "Internal error or synchronization notification failure"), @@ -202,6 +205,7 @@ fn mark_source( )] pub(crate) async fn put_store_entry( State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, Path(key): Path<String>, headers: HeaderMap, body: Bytes, @@ -210,7 +214,7 @@ pub(crate) async fn put_store_entry( return Err(StatusCode::SERVICE_UNAVAILABLE); } - let path = parse_key_path(&key)?; + let path = parse_write_path(&state, &user, &key, "PUT")?; let content_type = headers .get(CONTENT_TYPE) @@ -251,6 +255,7 @@ pub(crate) async fn put_store_entry( responses( (status = 204, description = "Value deleted successfully"), (status = 400, description = "Invalid store path"), + (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"), (status = 404, description = "Value does not exist"), (status = 500, description = "Deletion or synchronization notification failed"), (status = 503, description = "Node is not ready") @@ -259,10 +264,11 @@ pub(crate) async fn put_store_entry( #[instrument(skip(state), fields(path, prune = query.prune))] pub(crate) async fn delete_store_entry( State(state): State<AppState>, + Extension(user): Extension<AuthenticatedUser>, Path(key): Path<String>, Query(query): Query<DeleteQuery>, ) -> Result<StatusCode, StatusCode> { - let path = parse_key_path(&key)?; + let path = parse_write_path(&state, &user, &key, "DELETE")?; tracing::Span::current().record("path", path.join("/").as_str()); diff --git a/src/config/mod.rs b/src/config/mod.rs index 4914fb6..ef28542 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -82,6 +82,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "Users of the HTTP API (htpasswd file with Argon2 hashes). Required.", ), option( + "protocol-writers", + Kind::List, + "USER,[USER...]", + None, + "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata is never writable.", + ), + option( "debug-api", Kind::Flag, "BOOL", @@ -201,6 +208,8 @@ pub(crate) struct Settings { pub(crate) bind_addr: SocketAddr, pub(crate) htpasswd: String, #[serde(default)] + pub(crate) protocol_writers: Vec<String>, + #[serde(default)] pub(crate) debug_api: bool, #[serde(default)] pub(crate) seeds: Vec<String>, diff --git a/src/main.rs b/src/main.rs index 228fa54..98abf7a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,6 +140,7 @@ async fn main() -> Result<()> { alias: args.alias.clone(), electorate_window: Duration::from_secs(args.electorate_window_days * 24 * 3600), blobs: Arc::new(swarm::blob::Blobs::new(args.blob_chunk_kib * 1024)), + protocol_writers: Arc::new(args.protocol_writers.iter().cloned().collect()), config: Arc::new(config.report()), neighbours: Default::default(), pulling: Default::default(), diff --git a/src/state.rs b/src/state.rs index 1ea5aae..c51446f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -599,6 +599,9 @@ pub(crate) struct AppState { pub(crate) electorate_window: Duration, /// Chunked transfers served and fetched (`swarm::blob`). pub(crate) blobs: Arc<crate::swarm::blob::Blobs>, + /// HTTP API users who may write protocol data through the store API + /// (`--protocol-writers`). + pub(crate) protocol_writers: Arc<std::collections::HashSet<String>>, /// The effective configuration with sources, imported values left out /// (`config::Loaded::report`), for `GET /admin/config`. pub(crate) config: Arc<serde_json::Value>, |
