From 56f511d5f66e4fda6b7d79efc59403d17cfc0853 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 25 Sep 2026 19:56:44 +0200 Subject: [PATCH] Require authentication for RPCs and rate-limit fan-out methods - Every method except auth_challenge/auth_verify now requires a verified session (auth::require) and answers "Not authenticated" otherwise. Before, an unauthenticated connection could send emote_event notifications to any connected player and probe who is online with get_player. - Rate-limit per connection the methods that fan out to other players: emote (burst 5, +1/s) and send_player (burst 10, +1/s). - Cap emote/send_player targets at 256 and de-duplicate them. - Default SUPPORTED_VERSIONS to 0.1.1-beta. Co-Authored-By: Claude Opus 5.5 --- compose.yaml | 2 +- src/cosmetics.rs | 6 ++-- src/limits.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 21 ++++++++++-- src/methods/auth.rs | 11 ++++++ src/methods/emote.rs | 16 +++++++-- src/methods/user.rs | 10 +++++- 7 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 src/limits.rs diff --git a/compose.yaml b/compose.yaml index 479ef7f..26e2282 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ services: - "${HOST_PORT:-8080}:8080" environment: # Comma-separated client versions served at GET /versions. - SUPPORTED_VERSIONS: ${SUPPORTED_VERSIONS:-0.1.0-beta3} + SUPPORTED_VERSIONS: ${SUPPORTED_VERSIONS:-0.1.1-beta} DEPRECATED_VERSIONS: ${DEPRECATED_VERSIONS:-} volumes: - server-data:/data diff --git a/src/cosmetics.rs b/src/cosmetics.rs index 06f83d1..7b11038 100644 --- a/src/cosmetics.rs +++ b/src/cosmetics.rs @@ -44,8 +44,7 @@ pub async fn buy( item_id: String, pool: Arc, ) -> Result { - // Lock once - let uuid = uuid.lock().await.clone(); + let uuid = crate::methods::auth::require(&uuid).await?; let mut user = user::get(&uuid, &pool).await?; @@ -88,8 +87,7 @@ pub async fn equip( item_id: String, pool: Arc, ) -> Result { - // Lock once - let uuid = uuid.lock().await.clone(); + let uuid = crate::methods::auth::require(&uuid).await?; let mut user = user::get(&uuid, &pool).await?; diff --git a/src/limits.rs b/src/limits.rs new file mode 100644 index 0000000..08ccf0e --- /dev/null +++ b/src/limits.rs @@ -0,0 +1,79 @@ +use std::{ + collections::HashSet, + time::{Duration, Instant}, +}; + +use tokio::sync::Mutex; + +/// Most players a single `emote` or `send_player` request may target. +pub const MAX_TARGETS: usize = 256; + +/// Validates and de-duplicates a request's target UUIDs. +pub fn targets(targets: Vec) -> Result, String> { + if targets.len() > MAX_TARGETS { + return Err(format!("Too many targets (max {MAX_TARGETS})")); + } + + Ok(targets.into_iter().collect()) +} + +/// Token bucket: allows bursts of up to `burst` requests, refilled at one +/// token per `refill`. +pub struct RateLimiter { + state: Mutex<(f64, Instant)>, + burst: f64, + per_sec: f64, +} + +impl RateLimiter { + pub fn new(burst: u32, refill: Duration) -> Self { + Self { + state: Mutex::new((burst as f64, Instant::now())), + burst: burst as f64, + per_sec: 1.0 / refill.as_secs_f64(), + } + } + + pub async fn check(&self) -> Result<(), String> { + let mut state = self.state.lock().await; + let (tokens, last) = &mut *state; + + let now = Instant::now(); + *tokens = (*tokens + now.duration_since(*last).as_secs_f64() * self.per_sec).min(self.burst); + *last = now; + + if *tokens < 1.0 { + return Err("Rate limited, try again shortly".to_string()); + } + + *tokens -= 1.0; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn allows_a_burst_then_refills() { + let limiter = RateLimiter::new(2, Duration::from_millis(50)); + + assert!(limiter.check().await.is_ok()); + assert!(limiter.check().await.is_ok()); + assert!(limiter.check().await.is_err()); + + tokio::time::sleep(Duration::from_millis(60)).await; + assert!(limiter.check().await.is_ok()); + assert!(limiter.check().await.is_err()); + } + + #[test] + fn caps_and_dedupes_targets() { + let many = (0..=MAX_TARGETS).map(|i| i.to_string()).collect(); + assert!(targets(many).is_err()); + + let dupes = vec!["a".to_string(), "a".to_string(), "b".to_string()]; + assert_eq!(targets(dupes).unwrap().len(), 2); + } +} diff --git a/src/main.rs b/src/main.rs index 6b7a65e..3e0eaf2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod cosmetics; +mod limits; mod methods; mod types; mod user; @@ -51,7 +52,7 @@ impl VersionManifest { }; Self { - supported: list("SUPPORTED_VERSIONS", "0.1.0-beta3"), + supported: list("SUPPORTED_VERSIONS", "0.1.1-beta"), deprecated: list("DEPRECATED_VERSIONS", ""), } } @@ -121,6 +122,10 @@ async fn register_handlers(session: &Session, state: AppState) { let name = Arc::new(Mutex::new(String::new())); let pending: methods::auth::PendingChallenge = Arc::new(Mutex::new(None)); + // Per-connection limits on the methods that fan out to other players. + let emote_limit = Arc::new(limits::RateLimiter::new(5, Duration::from_secs(1))); + let send_player_limit = Arc::new(limits::RateLimiter::new(10, Duration::from_secs(1))); + session .on_close({ let session = session.clone(); @@ -245,7 +250,14 @@ async fn register_handlers(session: &Session, state: AppState) { let sessions = Arc::clone(&sessions); let uuid = Arc::clone(&uuid); - move |_, emote| methods::emote::send_emote(Arc::clone(&sessions), Arc::clone(&uuid), emote) + move |_, emote| { + methods::emote::send_emote( + Arc::clone(&sessions), + Arc::clone(&uuid), + Arc::clone(&emote_limit), + emote, + ) + } }) .await; @@ -254,7 +266,9 @@ async fn register_handlers(session: &Session, state: AppState) { let sessions = Arc::clone(&sessions); let pool = Arc::clone(&pool); - move |_, uuid| methods::user::get_user(sessions.clone(), uuid, pool.clone()) + let uuid = Arc::clone(&uuid); + + move |_, target| methods::user::get_user(sessions.clone(), uuid.clone(), target, pool.clone()) }) .await; @@ -270,6 +284,7 @@ async fn register_handlers(session: &Session, state: AppState) { sessions.clone(), name.clone(), uuid.clone(), + Arc::clone(&send_player_limit), targets.targets, pool.clone(), ) diff --git a/src/methods/auth.rs b/src/methods/auth.rs index 46afb7a..2df13dc 100644 --- a/src/methods/auth.rs +++ b/src/methods/auth.rs @@ -20,6 +20,17 @@ pub struct Challenge { pub type PendingChallenge = Arc>>; +/// Returns the connection's authenticated UUID, or an error before `auth_verify`. +pub async fn require(uuid: &UUID) -> Result { + let uuid = uuid.lock().await; + + if uuid.is_empty() { + return Err("Not authenticated".to_string()); + } + + Ok(uuid.clone()) +} + /// Minecraft usernames: 1–16 characters of letters, digits and underscores. fn is_valid_username(name: &str) -> bool { (1..=16).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') diff --git a/src/methods/emote.rs b/src/methods/emote.rs index 006ace0..f0180cc 100644 --- a/src/methods/emote.rs +++ b/src/methods/emote.rs @@ -1,16 +1,26 @@ -use crate::types::{SessionMap, UUID}; +use std::sync::Arc; + +use crate::{ + limits::{self, RateLimiter}, + types::{SessionMap, UUID}, +}; pub async fn send_emote( sessions: SessionMap, uuid: UUID, + limit: Arc, emote: crate::types::EmoteRequest, ) -> Result<(), String> { + let from = crate::methods::auth::require(&uuid).await?; + let targets = limits::targets(emote.targets)?; + limit.check().await?; + let emote_event = crate::types::EventEmote { - from: uuid.lock().await.clone(), + from, emote: emote.emote, }; - for target in emote.targets { + for target in targets { if let Some(sessions) = sessions.lock().await.get_mut(&target) { let emote_event = emote_event.clone(); let mut bad_sessions = Vec::new(); diff --git a/src/methods/user.rs b/src/methods/user.rs index a046e3a..9a33127 100644 --- a/src/methods/user.rs +++ b/src/methods/user.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use sqlx::SqlitePool; use crate::{ + limits::{self, RateLimiter}, methods, types::{PlayerStream, SessionMap, UUID}, user::User, @@ -10,9 +11,12 @@ use crate::{ pub async fn get_user( sessions: SessionMap, + caller: UUID, uuid: String, pool: Arc, ) -> Result, String> { + crate::methods::auth::require(&caller).await?; + if !sessions.lock().await.contains_key(&uuid) { return Ok(None); } @@ -24,11 +28,15 @@ pub async fn send_user( sessions: SessionMap, name: UUID, uuid: UUID, + limit: Arc, targets: Vec, pool: Arc, ) -> Result<(), String> { + let uuid = crate::methods::auth::require(&uuid).await?; + let targets = limits::targets(targets)?; + limit.check().await?; + println!("send player {targets:?}"); - let uuid = uuid.lock().await.to_string(); let user = PlayerStream { player: crate::user::get(&uuid, pool.as_ref()).await?,