[codex] Enforce shared relay admission limits (BUZZ-SEC-019) (#1917)

This commit is contained in:
Jordan Mecom
2026-07-17 16:25:09 +00:00
committed by GitHub
parent caa195ca58
commit 73fc0ec6cf
8 changed files with 383 additions and 3 deletions
+10
View File
@@ -51,6 +51,16 @@ RELAY_URL=ws://localhost:3000
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist
# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
# BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300
# BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10
# BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120
# BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN=600
# BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300
# BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600
# -----------------------------------------------------------------------------
# Git (NIP-34 bare repositories)
# -----------------------------------------------------------------------------
+3
View File
@@ -459,6 +459,9 @@ jobs:
BUZZ_BIND_ADDR=0.0.0.0:3000 \
BUZZ_REQUIRE_AUTH_TOKEN=false \
BUZZ_RECONCILE_CHANNELS=true \
BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \
BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \
BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \
BUZZ_GIT_PROBE_WRITERS=8 \
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
+158
View File
@@ -0,0 +1,158 @@
use buzz_auth::{LimitType, RateLimiter};
use buzz_core::TenantContext;
use nostr::PublicKey;
// Desktop startup establishes several independent live subscriptions at once.
// Preserve the configured average rate while allowing that bounded burst. This
// is still a fixed-window limiter, so a Redis-backed token bucket would be a
// better long-term fit for smoother refill behavior.
const WS_BURST_WINDOW_SECS: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AdmissionError {
Exceeded { reset_in_secs: u64 },
Unavailable,
}
pub(crate) async fn check_principal<L: RateLimiter>(
limiter: &L,
tenant: &TenantContext,
pubkey: &PublicKey,
limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> Result<(), AdmissionError> {
match limiter
.check_and_increment(tenant, pubkey, limit_type, window_secs, limit)
.await
{
Ok(result) if result.allowed => Ok(()),
Ok(result) => Err(AdmissionError::Exceeded {
reset_in_secs: result.reset_in_secs,
}),
Err(error) => {
tracing::warn!(error = %error, "shared rate-limit admission unavailable");
Err(AdmissionError::Unavailable)
}
}
}
pub(crate) fn ws_admission_budget(per_second_limit: u64) -> (u64, u64) {
(
WS_BURST_WINDOW_SECS,
per_second_limit.saturating_mul(WS_BURST_WINDOW_SECS),
)
}
#[cfg(test)]
mod tests {
use std::net::IpAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use buzz_auth::{AuthError, RateLimitResult, RateLimiter};
use buzz_core::CommunityId;
use nostr::Keys;
use uuid::Uuid;
use super::*;
enum StubOutcome {
Denied,
Failed,
}
struct StubLimiter {
outcome: StubOutcome,
calls: AtomicUsize,
}
impl RateLimiter for StubLimiter {
async fn check_and_increment(
&self,
_ctx: &TenantContext,
_pubkey: &PublicKey,
_limit_type: LimitType,
_window_secs: u64,
_limit: u64,
) -> Result<RateLimitResult, AuthError> {
self.calls.fetch_add(1, Ordering::Relaxed);
match self.outcome {
StubOutcome::Denied => Ok(RateLimitResult::denied(11, 10, 1)),
StubOutcome::Failed => Err(AuthError::Internal("redis unavailable".to_owned())),
}
}
async fn check_ip_connection(
&self,
_ip: &IpAddr,
_window_secs: u64,
_limit: u64,
) -> Result<RateLimitResult, AuthError> {
match self.outcome {
StubOutcome::Denied => Ok(RateLimitResult::denied(11, 10, 1)),
StubOutcome::Failed => Err(AuthError::Internal("redis unavailable".to_owned())),
}
}
}
fn tenant() -> TenantContext {
TenantContext::resolved(
CommunityId::from_uuid(Uuid::from_u128(1)),
"relay.example.com",
)
}
#[test]
fn websocket_budget_preserves_rate_with_a_bounded_burst() {
assert_eq!(ws_admission_budget(10), (5, 50));
}
#[test]
fn websocket_budget_saturates_on_overflow() {
assert_eq!(ws_admission_budget(u64::MAX), (5, u64::MAX));
}
#[tokio::test]
async fn denied_shared_counter_rejects_admission() {
let limiter = StubLimiter {
outcome: StubOutcome::Denied,
calls: AtomicUsize::new(0),
};
let keys = Keys::generate();
let result = check_principal(
&limiter,
&tenant(),
&keys.public_key(),
LimitType::WsEvents,
1,
10,
)
.await;
assert_eq!(result, Err(AdmissionError::Exceeded { reset_in_secs: 1 }));
assert_eq!(limiter.calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn shared_counter_failure_rejects_admission() {
let limiter = StubLimiter {
outcome: StubOutcome::Failed,
calls: AtomicUsize::new(0),
};
let keys = Keys::generate();
let result = check_principal(
&limiter,
&tenant(),
&keys.public_key(),
LimitType::ApiCalls,
60,
300,
)
.await;
assert_eq!(result, Err(AdmissionError::Unavailable));
assert_eq!(limiter.calls.load(Ordering::Relaxed), 1);
}
}
+38 -1
View File
@@ -13,7 +13,7 @@ use axum::{
use base64::Engine;
use serde_json::Value;
use buzz_auth::{Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
use buzz_core::TenantContext;
use crate::handlers::ingest::{IngestAuth, IngestError};
@@ -21,6 +21,40 @@ use crate::state::AppState;
use super::{api_error, internal_error, not_found};
async fn enforce_http_admission(
state: &AppState,
tenant: &TenantContext,
pubkey: &nostr::PublicKey,
) -> Result<(), (StatusCode, Json<Value>)> {
let limit = state.auth.config().rate_limits.human_api_calls_per_min;
match crate::admission::check_principal(
state.admission_rate_limiter.as_ref(),
tenant,
pubkey,
LimitType::ApiCalls,
60,
limit,
)
.await
{
Ok(()) => Ok(()),
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "quota").increment(1);
Err(api_error(
StatusCode::TOO_MANY_REQUESTS,
&format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"),
))
}
Err(crate::admission::AdmissionError::Unavailable) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "http", "reason" => "unavailable").increment(1);
Err(api_error(
StatusCode::SERVICE_UNAVAILABLE,
"rate-limited: shared admission unavailable",
))
}
}
}
/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
///
/// Returns the authenticated public key and an event ID for replay detection.
@@ -584,6 +618,7 @@ pub async fn submit_event(
Some(&body),
state.config.require_auth_token,
)?;
enforce_http_admission(&state, &tenant, &pubkey).await?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
@@ -653,6 +688,7 @@ pub async fn query_events(
Some(&body),
state.config.require_auth_token,
)?;
enforce_http_admission(&state, &tenant, &pubkey).await?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
@@ -1036,6 +1072,7 @@ pub async fn count_events(
Some(&body),
state.config.require_auth_token,
)?;
enforce_http_admission(&state, &tenant, &pubkey).await?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();
+82 -1
View File
@@ -233,6 +233,54 @@ fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
}
fn positive_u64_from_env(name: &str, default: u64) -> Result<u64, ConfigError> {
match std::env::var(name) {
Ok(raw) => raw
.parse::<u64>()
.ok()
.filter(|value| *value > 0)
.ok_or_else(|| ConfigError::InvalidValue(format!("{name} must be a positive integer"))),
Err(std::env::VarError::NotPresent) => Ok(default),
Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!(
"{name} must be valid Unicode"
))),
}
}
fn rate_limit_config_from_env() -> Result<buzz_auth::RateLimitConfig, ConfigError> {
let defaults = buzz_auth::RateLimitConfig::default();
Ok(buzz_auth::RateLimitConfig {
human_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN",
defaults.human_messages_per_min,
)?,
human_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN",
defaults.human_api_calls_per_min,
)?,
human_ws_events_per_sec: positive_u64_from_env(
"BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC",
defaults.human_ws_events_per_sec,
)?,
agent_standard_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN",
defaults.agent_standard_messages_per_min,
)?,
agent_standard_api_calls_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN",
defaults.agent_standard_api_calls_per_min,
)?,
agent_elevated_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN",
defaults.agent_elevated_messages_per_min,
)?,
agent_platform_messages_per_min: positive_u64_from_env(
"BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN",
defaults.agent_platform_messages_per_min,
)?,
})
}
fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
let raw = raw.trim();
let url = url::Url::parse(raw).map_err(|e| {
@@ -477,7 +525,9 @@ impl Config {
));
}
let auth = buzz_auth::AuthConfig::default();
let auth = buzz_auth::AuthConfig {
rate_limits: rate_limit_config_from_env()?,
};
if !require_auth_token {
warn!(
@@ -842,6 +892,37 @@ mod tests {
));
}
#[test]
fn rate_limits_can_be_overridden() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN", "1001");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN", "1002");
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "1003");
let config = Config::from_env().expect("config");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN");
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert_eq!(config.auth.rate_limits.human_messages_per_min, 1001);
assert_eq!(config.auth.rate_limits.human_api_calls_per_min, 1002);
assert_eq!(config.auth.rate_limits.human_ws_events_per_sec, 1003);
}
#[test]
fn rate_limit_overrides_reject_zero() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC", "0");
let result = Config::from_env();
std::env::remove_var("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC");
assert!(matches!(
result,
Err(ConfigError::InvalidValue(ref message))
if message.contains("BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC")
));
}
#[test]
fn relay_operator_pubkeys_parse_dedupe_and_normalize() {
let _guard = ENV_MUTEX.lock().unwrap();
+85 -1
View File
@@ -14,7 +14,7 @@ use tracing::Instrument as _;
use tracing::{debug, info, trace, warn};
use uuid::Uuid;
use buzz_auth::{generate_challenge, AuthContext};
use buzz_auth::{generate_challenge, AuthContext, LimitType};
use buzz_core::tenant::TenantContext;
use nostr::Filter;
@@ -495,6 +495,10 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
}
};
if !enforce_ws_admission(&msg, &conn, &state).await {
return;
}
match msg {
ClientMessage::Auth(event) => {
// Auth is synchronous in the WS loop — no span context is lost.
@@ -579,6 +583,86 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
}
}
async fn enforce_ws_admission(
msg: &ClientMessage,
conn: &ConnectionState,
state: &AppState,
) -> bool {
let is_event = matches!(msg, ClientMessage::Event(_));
if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) {
return true;
}
let (pubkey, is_agent) = {
let auth = conn.auth_state.read().await;
match &*auth {
AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()),
_ => return true,
}
};
let limits = &state.auth.config().rate_limits;
let (ws_window_secs, ws_limit) =
crate::admission::ws_admission_budget(limits.human_ws_events_per_sec);
let ws_result = crate::admission::check_principal(
state.admission_rate_limiter.as_ref(),
&conn.tenant,
&pubkey,
LimitType::WsEvents,
ws_window_secs,
ws_limit,
)
.await;
if !send_admission_result(conn, ws_result) {
return false;
}
if is_event {
let message_limit = if is_agent {
limits.agent_standard_messages_per_min
} else {
limits.human_messages_per_min
};
let message_result = crate::admission::check_principal(
state.admission_rate_limiter.as_ref(),
&conn.tenant,
&pubkey,
LimitType::Messages,
60,
message_limit,
)
.await;
if !send_admission_result(conn, message_result) {
return false;
}
}
true
}
fn send_admission_result(
conn: &ConnectionState,
result: Result<(), crate::admission::AdmissionError>,
) -> bool {
match result {
Ok(()) => true,
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1);
conn.send(RelayMessage::notice(&format!(
"rate-limited: quota exceeded; retry in {reset_in_secs}s"
)));
false
}
Err(crate::admission::AdmissionError::Unavailable) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1);
conn.send(RelayMessage::notice(
"rate-limited: shared admission unavailable",
));
false
}
}
}
fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
match channel_id {
Some(channel_id) => EventTopic::Channel(channel_id),
+2
View File
@@ -2,6 +2,8 @@
#![warn(missing_docs)]
//! NIP-01 WebSocket relay for Buzz private team communication.
mod admission;
/// REST API route handlers.
pub mod api;
/// WebSocket audio relay for huddle voice channels.
+5
View File
@@ -23,6 +23,7 @@ use buzz_db::Db;
use buzz_media::MediaStorage;
use buzz_pubsub::cache_invalidation::CacheInvalidation;
use buzz_pubsub::conn_control::ConnControl;
use buzz_pubsub::rate_limiter::RedisRateLimiter;
use buzz_pubsub::{PubSubManager, RedisNip98ReplayGuard};
use buzz_search::SearchService;
use buzz_workflow::WorkflowEngine;
@@ -518,6 +519,8 @@ pub struct AppState {
/// replace this with process-local caching; replay freshness must survive
/// cross-pod routing.
pub nip98_replay: Arc<dyn Nip98ReplayGuard>,
/// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work.
pub admission_rate_limiter: Arc<RedisRateLimiter>,
/// Per-agent sliding-window rate limiter for observer frames (kind 24200).
/// Key: (community_id, agent pubkey bytes). Value: (count, window_start).
@@ -627,6 +630,7 @@ impl AppState {
.expect("media storage was already constructed with this S3 config");
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));
let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone()));
let state = Self {
config: Arc::new(config),
db,
@@ -681,6 +685,7 @@ impl AppState {
shutting_down: Arc::new(AtomicBool::new(false)),
started_at: Instant::now(),
nip98_replay,
admission_rate_limiter,
observer_rate_limiter: Arc::new(DashMap::new()),
media_upload_rate_limiter: Arc::new(DashMap::new()),
invite_claim_rate_limiter: Arc::new(