Skip to content

Commit 51dc329

Browse files
authored
feat: Implement authentication rate limiting (fail2ban-like) (#155)
* feat: Implement authentication rate limiting (fail2ban-like) Add AuthRateLimiter with ban support to protect against brute-force attacks: - Track failed authentication attempts per IP address - Automatically ban IPs that exceed max attempts within time window - Configurable max attempts, time window, and ban duration - IP whitelist support for trusted addresses - Automatic cleanup of expired bans and failure records - Background cleanup task running every 60 seconds Configuration options added to SecurityConfig: - auth_window: Time window for counting attempts (default: 300s) - whitelist_ips: IPs exempt from rate limiting Integration with SSH handler: - Check if IP is banned before authentication - Record failures and trigger bans on threshold - Record success to reset failure counter - Logging for ban events Closes #140 * fix: Address PR review feedback for auth rate limiting - Use configuration values instead of hardcoded values for auth_window and ban_time - Integrate whitelist_ips from configuration with validation and logging - Fix TOCTOU race condition in record_failure by removing entry atomically - Add capacity limit (max_tracked_ips) to prevent memory exhaustion DoS - Use HashSet for whitelist O(1) lookups instead of Vec O(n) - Add auth rate limit config fields to ServerConfig - Propagate security config from ServerFileConfig to ServerConfig - Add test for capacity limit enforcement * chore: finalize auth rate limiter with docs and formatting fixes - Fix code formatting (cargo fmt) - Update ARCHITECTURE.md with Server Security Module documentation - Update server-configuration.md with auth_window and whitelist_ips options - All 930 tests passing, clippy clean
1 parent 0e5bcb3 commit 51dc329

8 files changed

Lines changed: 992 additions & 2 deletions

File tree

ARCHITECTURE.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ Common utilities for code reuse between bssh client and server implementations:
189189

190190
The `security` and `jump::rate_limiter` modules re-export from shared for backward compatibility.
191191

192+
### Server Security Module
193+
194+
Security features for the SSH server (`src/server/security/`):
195+
196+
- **AuthRateLimiter**: Fail2ban-like authentication rate limiting
197+
- Tracks failed authentication attempts per IP address
198+
- Automatic banning after exceeding configurable threshold
199+
- Time-windowed failure counting (failures outside window not counted)
200+
- Configurable ban duration with automatic expiration
201+
- IP whitelist for exempting trusted addresses from banning
202+
- Memory-safe with configurable maximum tracked IPs
203+
- Automatic cleanup of expired records via background task
204+
- Thread-safe async implementation with `Arc<RwLock<>>`
205+
192206
### Server CLI Binary
193207
**Binary**: `bssh-server`
194208

@@ -284,7 +298,8 @@ SSH server implementation using the russh library for accepting incoming connect
284298

285299
- **SshHandler**: Per-connection handler for SSH protocol events
286300
- Public key authentication via AuthProvider trait
287-
- Rate limiting for authentication attempts
301+
- Rate limiting for authentication attempts (token bucket)
302+
- Auth rate limiting with ban support (fail2ban-like)
288303
- Channel operations (open, close, EOF, data)
289304
- PTY, exec, shell, and subsystem request handling
290305
- Command execution with stdout/stderr streaming

docs/architecture/server-configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,19 @@ security:
173173
# Max auth attempts before banning IP
174174
max_auth_attempts: 5 # Default: 5
175175

176+
# Time window for counting auth attempts (seconds)
177+
# Failed attempts outside this window are not counted
178+
auth_window: 300 # Default: 300 (5 minutes)
179+
176180
# Ban duration after exceeding max attempts (seconds)
177181
ban_time: 300 # Default: 300 (5 minutes)
178182

183+
# IPs that are never banned (whitelist)
184+
# These IPs are exempt from rate limiting and banning
185+
whitelist_ips:
186+
- "127.0.0.1"
187+
- "::1"
188+
179189
# Max concurrent sessions per user
180190
max_sessions_per_user: 10 # Default: 10
181191

src/server/config/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,22 @@ pub struct ServerConfig {
149149
/// Configuration for command execution.
150150
#[serde(default)]
151151
pub exec: ExecConfig,
152+
153+
/// Time window for counting authentication attempts in seconds.
154+
///
155+
/// Default: 300 (5 minutes)
156+
#[serde(default = "default_auth_window_secs")]
157+
pub auth_window_secs: u64,
158+
159+
/// Ban duration in seconds after exceeding max auth attempts.
160+
///
161+
/// Default: 300 (5 minutes)
162+
#[serde(default = "default_ban_time_secs")]
163+
pub ban_time_secs: u64,
164+
165+
/// IP addresses that are never banned (whitelist).
166+
#[serde(default)]
167+
pub whitelist_ips: Vec<String>,
152168
}
153169

154170
/// Serializable configuration for public key authentication.
@@ -213,6 +229,14 @@ fn default_idle_timeout_secs() -> u64 {
213229
0 // 0 means no timeout
214230
}
215231

232+
fn default_auth_window_secs() -> u64 {
233+
300 // 5 minutes
234+
}
235+
236+
fn default_ban_time_secs() -> u64 {
237+
300 // 5 minutes
238+
}
239+
216240
fn default_true() -> bool {
217241
true
218242
}
@@ -233,6 +257,9 @@ impl Default for ServerConfig {
233257
publickey_auth: PublicKeyAuthConfigSerde::default(),
234258
password_auth: PasswordAuthConfigSerde::default(),
235259
exec: ExecConfig::default(),
260+
auth_window_secs: default_auth_window_secs(),
261+
ban_time_secs: default_ban_time_secs(),
262+
whitelist_ips: Vec::new(),
236263
}
237264
}
238265
}
@@ -521,6 +548,9 @@ impl ServerFileConfig {
521548
allowed_commands: None,
522549
blocked_commands: Vec::new(),
523550
},
551+
auth_window_secs: self.security.auth_window,
552+
ban_time_secs: self.security.ban_time,
553+
whitelist_ips: self.security.whitelist_ips,
524554
}
525555
}
526556
}

src/server/config/types.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,12 +368,28 @@ pub struct SecurityConfig {
368368
#[serde(default = "default_max_auth_attempts")]
369369
pub max_auth_attempts: u32,
370370

371+
/// Time window in seconds for counting authentication attempts.
372+
///
373+
/// Failed attempts outside this window are not counted toward the ban threshold.
374+
///
375+
/// Default: 300 (5 minutes)
376+
#[serde(default = "default_auth_window")]
377+
pub auth_window: u64,
378+
371379
/// Ban duration in seconds after exceeding max auth attempts.
372380
///
373381
/// Default: 300 (5 minutes)
374382
#[serde(default = "default_ban_time")]
375383
pub ban_time: u64,
376384

385+
/// IP addresses that are never banned (whitelist).
386+
///
387+
/// These IPs are exempt from rate limiting and banning.
388+
///
389+
/// Example: ["127.0.0.1", "::1"]
390+
#[serde(default)]
391+
pub whitelist_ips: Vec<String>,
392+
377393
/// Maximum number of concurrent sessions per user.
378394
///
379395
/// Default: 10
@@ -449,6 +465,10 @@ fn default_max_auth_attempts() -> u32 {
449465
5
450466
}
451467

468+
fn default_auth_window() -> u64 {
469+
300
470+
}
471+
452472
fn default_ban_time() -> u64 {
453473
300
454474
}
@@ -517,7 +537,9 @@ impl Default for SecurityConfig {
517537
fn default() -> Self {
518538
Self {
519539
max_auth_attempts: default_max_auth_attempts(),
540+
auth_window: default_auth_window(),
520541
ban_time: default_ban_time(),
542+
whitelist_ips: Vec::new(),
521543
max_sessions_per_user: default_max_sessions(),
522544
idle_timeout: default_idle_timeout(),
523545
allowed_ips: Vec::new(),

src/server/handler.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use super::auth::AuthProvider;
3232
use super::config::ServerConfig;
3333
use super::exec::CommandExecutor;
3434
use super::pty::PtyConfig as PtyMasterConfig;
35+
use super::security::AuthRateLimiter;
3536
use super::session::{ChannelState, PtyConfig, SessionId, SessionInfo, SessionManager};
3637
use super::sftp::SftpHandler;
3738
use super::shell::ShellSession;
@@ -57,6 +58,9 @@ pub struct SshHandler {
5758
/// Rate limiter for authentication attempts.
5859
rate_limiter: RateLimiter<String>,
5960

61+
/// Auth rate limiter with ban support (fail2ban-like).
62+
auth_rate_limiter: Option<AuthRateLimiter>,
63+
6064
/// Session information for this connection.
6165
session_info: Option<SessionInfo>,
6266

@@ -83,6 +87,7 @@ impl SshHandler {
8387
sessions,
8488
auth_provider,
8589
rate_limiter,
90+
auth_rate_limiter: None,
8691
session_info: Some(SessionInfo::new(peer_addr)),
8792
channels: HashMap::new(),
8893
}
@@ -106,6 +111,33 @@ impl SshHandler {
106111
sessions,
107112
auth_provider,
108113
rate_limiter,
114+
auth_rate_limiter: None,
115+
session_info: Some(SessionInfo::new(peer_addr)),
116+
channels: HashMap::new(),
117+
}
118+
}
119+
120+
/// Create a new SSH handler with shared rate limiters including auth ban support.
121+
///
122+
/// This is the preferred constructor for production use as it shares
123+
/// both rate limiters across all handlers, providing server-wide rate limiting
124+
/// and fail2ban-like functionality.
125+
pub fn with_rate_limiters(
126+
peer_addr: Option<SocketAddr>,
127+
config: Arc<ServerConfig>,
128+
sessions: Arc<RwLock<SessionManager>>,
129+
rate_limiter: RateLimiter<String>,
130+
auth_rate_limiter: AuthRateLimiter,
131+
) -> Self {
132+
let auth_provider = config.create_auth_provider();
133+
134+
Self {
135+
peer_addr,
136+
config,
137+
sessions,
138+
auth_provider,
139+
rate_limiter,
140+
auth_rate_limiter: Some(auth_rate_limiter),
109141
session_info: Some(SessionInfo::new(peer_addr)),
110142
channels: HashMap::new(),
111143
}
@@ -128,6 +160,7 @@ impl SshHandler {
128160
sessions,
129161
auth_provider,
130162
rate_limiter,
163+
auth_rate_limiter: None,
131164
session_info: Some(SessionInfo::new(peer_addr)),
132165
channels: HashMap::new(),
133166
}
@@ -284,6 +317,7 @@ impl russh::server::Handler for SshHandler {
284317
// Clone what we need for the async block
285318
let auth_provider = Arc::clone(&self.auth_provider);
286319
let rate_limiter = self.rate_limiter.clone();
320+
let auth_rate_limiter = self.auth_rate_limiter.clone();
287321
let peer_addr = self.peer_addr;
288322
let user = user.to_string();
289323
let public_key = public_key.clone();
@@ -292,6 +326,23 @@ impl russh::server::Handler for SshHandler {
292326
let session_info = &mut self.session_info;
293327

294328
async move {
329+
// Check if IP is banned (fail2ban-like check)
330+
if let Some(ref limiter) = auth_rate_limiter {
331+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
332+
if limiter.is_banned(&ip).await {
333+
tracing::warn!(
334+
user = %user,
335+
peer = ?peer_addr,
336+
"Rejected auth from banned IP"
337+
);
338+
return Ok(Auth::Reject {
339+
proceed_with_methods: None,
340+
partial_success: false,
341+
});
342+
}
343+
}
344+
}
345+
295346
if exceeded {
296347
tracing::warn!(
297348
user = %user,
@@ -349,6 +400,13 @@ impl russh::server::Handler for SshHandler {
349400
info.authenticate(&user);
350401
}
351402

403+
// Record success to reset failure counter
404+
if let Some(ref limiter) = auth_rate_limiter {
405+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
406+
limiter.record_success(&ip).await;
407+
}
408+
}
409+
352410
Ok(Auth::Accept)
353411
}
354412
Ok(_) => {
@@ -359,6 +417,20 @@ impl russh::server::Handler for SshHandler {
359417
"Public key authentication rejected"
360418
);
361419

420+
// Record failure for ban tracking
421+
if let Some(ref limiter) = auth_rate_limiter {
422+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
423+
let banned = limiter.record_failure(ip).await;
424+
if banned {
425+
tracing::warn!(
426+
user = %user,
427+
peer = ?peer_addr,
428+
"IP banned due to too many failed auth attempts"
429+
);
430+
}
431+
}
432+
}
433+
362434
let proceed = if methods.is_empty() {
363435
None
364436
} else {
@@ -378,6 +450,13 @@ impl russh::server::Handler for SshHandler {
378450
"Error during public key verification"
379451
);
380452

453+
// Record failure for ban tracking
454+
if let Some(ref limiter) = auth_rate_limiter {
455+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
456+
limiter.record_failure(ip).await;
457+
}
458+
}
459+
381460
let proceed = if methods.is_empty() {
382461
None
383462
} else {
@@ -421,6 +500,7 @@ impl russh::server::Handler for SshHandler {
421500
// Clone what we need for the async block
422501
let auth_provider = Arc::clone(&self.auth_provider);
423502
let rate_limiter = self.rate_limiter.clone();
503+
let auth_rate_limiter = self.auth_rate_limiter.clone();
424504
let peer_addr = self.peer_addr;
425505
let user = user.to_string();
426506
// Use Zeroizing to ensure password is securely cleared from memory when dropped
@@ -431,6 +511,23 @@ impl russh::server::Handler for SshHandler {
431511
let session_info = &mut self.session_info;
432512

433513
async move {
514+
// Check if IP is banned (fail2ban-like check)
515+
if let Some(ref limiter) = auth_rate_limiter {
516+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
517+
if limiter.is_banned(&ip).await {
518+
tracing::warn!(
519+
user = %user,
520+
peer = ?peer_addr,
521+
"Rejected password auth from banned IP"
522+
);
523+
return Ok(Auth::Reject {
524+
proceed_with_methods: None,
525+
partial_success: false,
526+
});
527+
}
528+
}
529+
}
530+
434531
// Check if password auth is enabled
435532
if !allow_password {
436533
tracing::debug!(
@@ -504,6 +601,13 @@ impl russh::server::Handler for SshHandler {
504601
info.authenticate(&user);
505602
}
506603

604+
// Record success to reset failure counter
605+
if let Some(ref limiter) = auth_rate_limiter {
606+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
607+
limiter.record_success(&ip).await;
608+
}
609+
}
610+
507611
Ok(Auth::Accept)
508612
}
509613
Ok(_) => {
@@ -513,6 +617,20 @@ impl russh::server::Handler for SshHandler {
513617
"Password authentication rejected"
514618
);
515619

620+
// Record failure for ban tracking
621+
if let Some(ref limiter) = auth_rate_limiter {
622+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
623+
let banned = limiter.record_failure(ip).await;
624+
if banned {
625+
tracing::warn!(
626+
user = %user,
627+
peer = ?peer_addr,
628+
"IP banned due to too many failed password auth attempts"
629+
);
630+
}
631+
}
632+
}
633+
516634
let proceed = if methods.is_empty() {
517635
None
518636
} else {
@@ -532,6 +650,13 @@ impl russh::server::Handler for SshHandler {
532650
"Error during password verification"
533651
);
534652

653+
// Record failure for ban tracking
654+
if let Some(ref limiter) = auth_rate_limiter {
655+
if let Some(ip) = peer_addr.map(|a| a.ip()) {
656+
limiter.record_failure(ip).await;
657+
}
658+
}
659+
535660
let proceed = if methods.is_empty() {
536661
None
537662
} else {

0 commit comments

Comments
 (0)