CLI: add SSH auth rate limiting, sanitize API errors, log destructive actions - Per-IP rate limiter (10 failures/60s window) for SSH key auth - Strip raw response bodies from error messages shown to clients - Add structured tracing for item/blog/promo/key deletions
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-16 15:25 UTC
Commit:
9247ad5e3e238172715500f30f98b3646be5411aParent:
6 files changed,
+194 insertions,
-13 deletions
OldNewLine
@@ -7,6 +7,26 @@
7
7
use crate::api::{MnwApiClient, UserInfo};8
8
use crate::format;9
9
10
/// Sanitize an API error for display to SSH clients.11
///12
/// Strips raw response bodies (HTML error pages, stack traces) that may leak13
/// from anyhow error chains. Keeps the high-level context + HTTP status code14
/// but drops everything after ` — ` (the body separator our API client uses).15
pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String {16
let msg = e.to_string();17
// Our API client formats errors as "context: HTTP 500 — <body>".18
// Keep the part before the body separator.19
if let Some(idx) = msg.find(" \u{2014} ") {20
return msg[..idx].to_string();21
}22
// For connection/timeout errors, return a generic message.23
if msg.contains("connect") || msg.contains("timed out") || msg.contains("dns") {24
return "Service temporarily unavailable".to_string();25
}26
// Fallback: return the first line only (avoid multi-line leaks).27
msg.lines().next().unwrap_or("Unknown error").to_string()28
}29
10
30
/// Execute a non-interactive command and return the output bytes.11
31
pub async fn execute(12
32
command_line: &str,@@ -83,7 +103,7 @@
83
103
}84
104
out.into_bytes()85
105
}86
Err(e) => format!("Error: {e}\r\n").into_bytes(),106
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),87
107
}88
108
}89
109
@@ -127,7 +147,7 @@
127
147
128
148
out.into_bytes()129
149
}130
Err(e) => format!("Error: {e}\r\n").into_bytes(),150
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),131
151
}132
152
}133
153
@@ -160,14 +180,14 @@
160
180
}161
181
out.into_bytes()162
182
}163
Err(e) => format!("Error: {e}\r\n").into_bytes(),183
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),164
184
}165
185
}166
186
167
187
async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {168
188
match api.export_sales_csv(&user.user_id).await {169
189
Ok(result) => result.csv.into_bytes(),170
Err(e) => format!("Error: {e}\r\n").into_bytes(),190
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),171
191
}172
192
}173
193
@@ -211,7 +231,7 @@
211
231
}212
232
out.into_bytes()213
233
}214
Err(e) => format!("Error: {e}\r\n").into_bytes(),234
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),215
235
}216
236
}217
237
@@ -230,7 +250,7 @@
230
250
.await231
251
{232
252
Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(),233
Err(e) => format!("Error: {e}\r\n").into_bytes(),253
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),234
254
}235
255
}236
256
@@ -247,7 +267,7 @@
247
267
// Find the project by slug248
268
let projects = match api.get_projects(&user.user_id).await {249
269
Ok(p) => p,250
Err(e) => return format!("Error: {e}\r\n").into_bytes(),270
Err(e) => return format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),251
271
};252
272
253
273
let Some(project) = projects.iter().find(|p| p.slug == slug) else {@@ -281,7 +301,7 @@
281
301
}282
302
out.into_bytes()283
303
}284
Err(e) => format!("Error: {e}\r\n").into_bytes(),304
Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),285
305
}286
306
}287
307
OldNewLine
@@ -8,6 +8,7 @@
8
8
mod commands;9
9
mod config;10
10
mod format;11
mod rate_limit;11
12
mod ssh;12
13
mod staging;13
14
mod tui;@@ -66,7 +67,8 @@
66
67
67
68
let staging_dir = Arc::new(config.staging_dir);68
69
let api_client = api::MnwApiClient::new(config.api_url, config.service_token);69
let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user);70
let rate_limiter = Arc::new(rate_limit::AuthRateLimiter::new());71
let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user, rate_limiter);70
72
71
73
let addr = format!("0.0.0.0:{}", config.port);72
74
tracing::info!(%addr, "listening for SSH connections");OldNewLine
@@ -11,6 +11,7 @@
11
11
use tokio::sync::Mutex;12
12
13
13
use crate::api::{MnwApiClient, UserInfo};14
use crate::rate_limit::AuthRateLimiter;14
15
use crate::ssh::git;15
16
use crate::ssh::sftp::SftpSession;16
17
use crate::ssh::terminal::TerminalHandle;@@ -23,6 +24,7 @@
23
24
peer_addr: Option<SocketAddr>,24
25
staging_dir: Arc<PathBuf>,25
26
git_user: Arc<str>,27
rate_limiter: Arc<AuthRateLimiter>,26
28
/// Populated after successful auth.27
29
user: Option<UserInfo>,28
30
/// Terminal dimensions (cols, rows).@@ -41,12 +43,14 @@
41
43
peer_addr: Option<SocketAddr>,42
44
staging_dir: Arc<PathBuf>,43
45
git_user: Arc<str>,46
rate_limiter: Arc<AuthRateLimiter>,44
47
) -> Self {45
48
Self {46
49
api,47
50
peer_addr,48
51
staging_dir,49
52
git_user,53
rate_limiter,50
54
user: None,51
55
term_size: (80, 24),52
56
app: None,@@ -64,6 +68,17 @@
64
68
_user: &str,65
69
key: &PublicKey,66
70
) -> Result<Auth, Self::Error> {71
// Per-IP rate limiting: reject early if threshold exceeded72
if let Some(addr) = self.peer_addr {73
if !self.rate_limiter.check(addr.ip()) {74
tracing::warn!(peer = %addr, "auth rate limit exceeded");75
return Ok(Auth::Reject {76
proceed_with_methods: None,77
partial_success: false,78
});79
}80
}81
67
82
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();68
83
tracing::debug!(%fingerprint, peer = ?self.peer_addr, "key offered");69
84
@@ -71,6 +86,9 @@
71
86
Ok(Some(info)) => {72
87
if info.suspended {73
88
tracing::warn!(user = %info.username, "suspended user attempted SSH login");89
if let Some(addr) = self.peer_addr {90
self.rate_limiter.record_failure(addr.ip());91
}74
92
return Ok(Auth::Reject {75
93
proceed_with_methods: None,76
94
partial_success: false,@@ -81,6 +99,9 @@
81
99
}82
100
Ok(None) => {83
101
tracing::debug!(%fingerprint, "key not found");102
if let Some(addr) = self.peer_addr {103
self.rate_limiter.record_failure(addr.ip());104
}84
105
Ok(Auth::Reject {85
106
proceed_with_methods: None,86
107
partial_success: false,@@ -280,8 +301,10 @@
280
301
}281
302
}282
303
Err(e) => {283
let msg =284
bytes::Bytes::from(format!("fatal: {e}\r\n"));304
let msg = bytes::Bytes::from(format!(305
"fatal: {}\r\n",306
crate::commands::sanitize_api_error(&e)307
));285
308
let _ = handle.extended_data(channel, 1, msg).await;286
309
let _ = handle.exit_status_request(channel, 1).await;287
310
let _ = handle.eof(channel).await;OldNewLine
@@ -10,20 +10,28 @@
10
10
use std::sync::Arc;11
11
12
12
use crate::api::MnwApiClient;13
use crate::rate_limit::AuthRateLimiter;13
14
14
15
/// SSH server that spawns a new handler per connection.15
16
pub struct MnwServer {16
17
api: MnwApiClient,17
18
staging_dir: Arc<PathBuf>,18
19
git_user: Arc<str>,20
rate_limiter: Arc<AuthRateLimiter>,19
21
}20
22
21
23
impl MnwServer {22
pub fn new(api: MnwApiClient, staging_dir: Arc<PathBuf>, git_user: String) -> Self {24
pub fn new(25
api: MnwApiClient,26
staging_dir: Arc<PathBuf>,27
git_user: String,28
rate_limiter: Arc<AuthRateLimiter>,29
) -> Self {23
30
Self {24
31
api,25
32
staging_dir,26
33
git_user: Arc::from(git_user),34
rate_limiter,27
35
}28
36
}29
37
}@@ -38,6 +46,7 @@
38
46
peer_addr,39
47
Arc::clone(&self.staging_dir),40
48
Arc::clone(&self.git_user),49
Arc::clone(&self.rate_limiter),41
50
)42
51
}43
52
}OldNewLine
@@ -642,7 +642,14 @@
642
642
// Confirmed — execute delete643
643
app.confirm_action = None;644
644
let item_id = detail.id.clone();645
let item_title = detail.title.clone();645
646
let user_id = app.user.user_id.clone();647
tracing::info!(648
user_id = %user_id,649
item_id = %item_id,650
item_title = %item_title,651
"delete item confirmed"652
);646
653
let api = api.clone();647
654
let tx = tx.clone();648
655
app.item_status = Some("Deleting...".to_string());@@ -818,7 +825,14 @@
818
825
// Confirmed — execute delete819
826
app.confirm_action = None;820
827
let post_id = app.blog_posts[idx].id.clone();828
let post_title = app.blog_posts[idx].title.clone();821
829
let user_id = app.user.user_id.clone();830
tracing::info!(831
user_id = %user_id,832
post_id = %post_id,833
post_title = %post_title,834
"delete blog post confirmed"835
);822
836
let api = api.clone();823
837
let tx = tx.clone();824
838
@@ -971,8 +985,15 @@
971
985
// Confirmed — execute delete972
986
app.confirm_action = None;973
987
let code_id = app.promo_codes[idx].id.clone();974
let api = api.clone();988
let code_value = app.promo_codes[idx].code.clone();975
989
let user_id = app.user.user_id.clone();990
tracing::info!(991
user_id = %user_id,992
code_id = %code_id,993
code = %code_value,994
"delete promo code confirmed"995
);996
let api = api.clone();976
997
let tx = tx.clone();977
998
app.promo_status = Some("Deleting...".to_string());978
999
tokio::spawn(async move {@@ -1082,6 +1103,14 @@
1082
1103
// Confirmed — execute revoke1083
1104
app.confirm_action = None;1084
1105
let key_id = app.license_keys[idx].id.clone();1106
let key_code = app.license_keys[idx].key_code.clone();1107
let user_id = app.user.user_id.clone();1108
tracing::info!(1109
user_id = %user_id,1110
key_id = %key_id,1111
key_code = %key_code,1112
"revoke license key confirmed"1113
);1085
1114
if let Screen::Keys(_, item_id) = &*screen {1086
1115
let item_id = item_id.clone();1087
1116
let api = api.clone();OldNewLine
@@ -1,0 +1,98 @@
1
//! Per-IP authentication rate limiting.2
//!3
//! russh's `auth_rejection_time` only delays within a single connection.4
//! Parallel connections bypass it. This module tracks failed auth attempts5
//! per IP and rejects early when a threshold is exceeded.6
7
use std::collections::HashMap;8
use std::net::IpAddr;9
use std::sync::Mutex;10
use std::time::Instant;11
12
const MAX_FAILURES: usize = 10;13
const WINDOW_SECS: u64 = 60;14
const PRUNE_THRESHOLD: usize = 1000;15
16
pub struct AuthRateLimiter {17
failures: Mutex<HashMap<IpAddr, Vec<Instant>>>,18
}19
20
impl AuthRateLimiter {21
pub fn new() -> Self {22
Self {23
failures: Mutex::new(HashMap::new()),24
}25
}26
27
/// Returns `true` if the IP is allowed to attempt auth.28
/// Returns `false` if the IP has exceeded the failure threshold.29
pub fn check(&self, ip: IpAddr) -> bool {30
let mut map = self.failures.lock().unwrap();31
let cutoff = Instant::now() - std::time::Duration::from_secs(WINDOW_SECS);32
33
if let Some(times) = map.get_mut(&ip) {34
times.retain(|t| *t > cutoff);35
times.len() < MAX_FAILURES36
} else {37
true38
}39
}40
41
/// Record a failed auth attempt for the given IP.42
pub fn record_failure(&self, ip: IpAddr) {43
let mut map = self.failures.lock().unwrap();44
45
// Prune stale entries when map grows large46
if map.len() > PRUNE_THRESHOLD {47
let cutoff = Instant::now() - std::time::Duration::from_secs(WINDOW_SECS);48
map.retain(|_, times| {49
times.retain(|t| *t > cutoff);50
!times.is_empty()51
});52
}53
54
map.entry(ip).or_default().push(Instant::now());55
}56
}57
58
#[cfg(test)]59
mod tests {60
use super::*;61
use std::net::Ipv4Addr;62
63
#[test]64
fn allows_under_threshold() {65
let limiter = AuthRateLimiter::new();66
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));67
68
for _ in 0..MAX_FAILURES - 1 {69
assert!(limiter.check(ip));70
limiter.record_failure(ip);71
}72
assert!(limiter.check(ip));73
}74
75
#[test]76
fn blocks_at_threshold() {77
let limiter = AuthRateLimiter::new();78
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));79
80
for _ in 0..MAX_FAILURES {81
limiter.record_failure(ip);82
}83
assert!(!limiter.check(ip));84
}85
86
#[test]87
fn independent_ips() {88
let limiter = AuthRateLimiter::new();89
let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));90
let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2));91
92
for _ in 0..MAX_FAILURES {93
limiter.record_failure(ip_a);94
}95
assert!(!limiter.check(ip_a));96
assert!(limiter.check(ip_b));97
}98
}