Skip to main content

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-ByClaude Opus 4.6 <noreply@anthropic.com>

Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-16 15:25 UTC

Commit:

9247ad5e3e238172715500f30f98b3646be5411a

Parent:

5f096f5

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 leak
13
/// from anyhow error chains. Keeps the high-level context + HTTP status code
14
/// 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
.await
231
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 slug
248
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 exceeded
72
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 delete
643
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 delete
819
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 delete
972
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 revoke
1083
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 attempts
5
//! 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_FAILURES
36
} else {
37
true
38
}
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 large
46
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
}