From 00351c8fa1a4ab12ffb0862184b14540676016be Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Thu, 23 Jul 2026 21:05:31 +0400 Subject: [PATCH] fix(faucet): rate-limit the faucet to prevent testnet drain POST /faucet had no rate limiting, so any caller could repeatedly drain the funded testnet faucet account. Add an in-memory cooldown limiter (std only, no new dependency) keyed by: - client IP: 30s window (anti-spam), read via realip_remote_addr so it uses X-Forwarded-For behind the nginx reverse proxy, not the proxy IP; - recipient address: 1h window (anti-refund drain), case-insensitive. Rejected requests return 429 with a Retry-After header and do not record a hit, so a blocked caller cannot push its own cooldown forward. The limiter is created once and shared across all Actix workers (a per-worker instance would multiply the limit by the worker count). The map self-prunes to the longest window to stay bounded. Cooldowns are fixed constants for now (marked with ponytail: comments as the tuning knob). Captcha/PoW and per-period amount caps are out of scope: an attacker rotating both IP and address still bypasses a keyless faucet. Adds unit tests for the cooldown and case-insensitivity behavior. Co-Authored-By: Claude Opus 4.8 --- src/hub/mod.rs | 1 + src/hub/rate_limit.rs | 101 ++++++++++++++++++++++++++++++++++++++++++ src/hub/server.rs | 23 +++++++++- 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/hub/rate_limit.rs diff --git a/src/hub/mod.rs b/src/hub/mod.rs index 2931a09..5f1a459 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -4,6 +4,7 @@ pub mod configuration; pub mod faucet; pub mod graphql; pub mod metric; +pub mod rate_limit; pub mod referrer; pub mod seq; pub mod server; diff --git a/src/hub/rate_limit.rs b/src/hub/rate_limit.rs new file mode 100644 index 0000000..fa44a79 --- /dev/null +++ b/src/hub/rate_limit.rs @@ -0,0 +1,101 @@ +//! In-memory cooldown limiter for the testnet faucet. +//! Keyed by client IP (anti-spam) and recipient address (anti-refund drain). + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +pub struct FaucetRateLimiter { + // ponytail: one map, "ip:"/"addr:" prefixed keys; pruned to the per-address + // horizon on each call so it stays bounded — fine for testnet volume. LRU if it grows. + hits: Mutex>, + per_ip: Duration, + per_address: Duration, +} + +impl FaucetRateLimiter { + // ponytail: fixed cooldowns; move to AppConfig if operators need to tune them. + pub fn new() -> Self { + Self::with_windows(Duration::from_secs(30), Duration::from_secs(3600)) + } + + pub fn with_windows(per_ip: Duration, per_address: Duration) -> Self { + Self { + hits: Mutex::new(HashMap::new()), + per_ip, + per_address, + } + } + + /// Record a hit for (ip, address). Returns `Err(retry_after_secs)` if either the IP + /// or the address is still cooling down; on rejection the hit is NOT recorded. + pub fn check(&self, ip: &str, address: &str) -> Result<(), u64> { + let now = Instant::now(); + // Recover a poisoned lock rather than wedging the faucet forever. + let mut hits = self.hits.lock().unwrap_or_else(|p| p.into_inner()); + + // Drop entries older than the longest window so the map stays bounded. + hits.retain(|_, t| now.saturating_duration_since(*t) < self.per_address); + + let ip_key = format!("ip:{}", ip); + let addr_key = format!("addr:{}", address.to_lowercase()); + + if let Some(retry) = Self::remaining(&hits, &ip_key, self.per_ip, now) { + return Err(retry); + } + if let Some(retry) = Self::remaining(&hits, &addr_key, self.per_address, now) { + return Err(retry); + } + + hits.insert(ip_key, now); + hits.insert(addr_key, now); + Ok(()) + } + + fn remaining( + hits: &HashMap, + key: &str, + window: Duration, + now: Instant, + ) -> Option { + let last = hits.get(key)?; + let elapsed = now.saturating_duration_since(*last); + if elapsed < window { + Some((window - elapsed).as_secs() + 1) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread::sleep; + + #[test] + fn blocks_rapid_repeat_then_allows_after_window() { + let rl = FaucetRateLimiter::with_windows( + Duration::from_millis(40), + Duration::from_millis(40), + ); + // First hit allowed. + assert!(rl.check("1.2.3.4", "0xabc").is_ok()); + // Immediate repeat from the same IP is blocked, even for a different address. + assert!(rl.check("1.2.3.4", "0xother").is_err()); + // Same address from a different IP is blocked by the address window. + assert!(rl.check("9.9.9.9", "0xabc").is_err()); + // After the window elapses, allowed again. + sleep(Duration::from_millis(60)); + assert!(rl.check("1.2.3.4", "0xabc").is_ok()); + } + + #[test] + fn address_key_is_case_insensitive() { + let rl = + FaucetRateLimiter::with_windows(Duration::from_secs(10), Duration::from_secs(10)); + assert!(rl.check("1.1.1.1", "0xABC").is_ok()); + // Different IP, same address in different case — still blocked. + assert!(rl.check("2.2.2.2", "0xabc").is_err()); + } +} diff --git a/src/hub/server.rs b/src/hub/server.rs index 12a3fae..097bffb 100644 --- a/src/hub/server.rs +++ b/src/hub/server.rs @@ -3,9 +3,10 @@ use crate::hub::configuration::AppConfig; use crate::hub::faucet::execute_faucet; use crate::hub::graphql::build_schema; use crate::hub::graphql::handler::{graphql_handler, graphql_ws_handler}; +use crate::hub::rate_limit::FaucetRateLimiter; use crate::hub::signature_keys::SignatureKeys; use actix_cors::Cors; -use actix_web::{web, App, HttpResponse, HttpServer, Result}; +use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Result}; use serde::Deserialize; use std::sync::Arc; @@ -29,9 +30,11 @@ pub struct FaucetRequestBody { } async fn post_faucet( + req: HttpRequest, body: web::Json, client: web::Data>, config: web::Data, + rate_limiter: web::Data, ) -> HttpResponse { if !config.faucet_enabled { return HttpResponse::ServiceUnavailable().json(serde_json::json!({ @@ -46,6 +49,21 @@ async fn post_faucet( if let Err(e) = SignatureKeys::validate_public_key(&body.address) { return HttpResponse::BadRequest().json(serde_json::json!({ "error": e })); } + // Real client IP behind the reverse proxy (X-Forwarded-For / X-Real-IP), else socket peer. + // ponytail: XFF is client-spoofable if not behind a trusted proxy; the deploy runs nginx. + let client_ip = req + .connection_info() + .realip_remote_addr() + .unwrap_or("unknown") + .to_string(); + if let Err(retry_after) = rate_limiter.check(&client_ip, body.address.trim()) { + return HttpResponse::TooManyRequests() + .insert_header(("Retry-After", retry_after.to_string())) + .json(serde_json::json!({ + "error": "faucet cooldown active, try again later", + "retry_after_secs": retry_after + })); + } match execute_faucet( client.get_ref(), config.faucet_private_key.trim(), @@ -69,6 +87,8 @@ pub async fn run_graphql_server( config: AppConfig, ) -> std::io::Result<()> { let schema = build_schema(ws_manager.clone(), config.clone()); + // Shared once across all workers (per-worker Data::new would multiply the limit by worker count). + let rate_limiter = web::Data::new(FaucetRateLimiter::new()); HttpServer::new(move || { App::new() .wrap( @@ -80,6 +100,7 @@ pub async fn run_graphql_server( .app_data(web::Data::new(config.clone())) .app_data(web::Data::new(schema.clone())) .app_data(web::Data::new(ws_manager.clone())) + .app_data(rate_limiter.clone()) .service(web::resource("/health").route(web::get().to(health_check))) .service(web::resource("/faucet").route(web::post().to(post_faucet))) .service(web::resource("/graphql").route(web::post().to(graphql_handler)))