Skip to main content

max / makenotwork

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: Claude 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
@@ -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 @@ async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8
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 @@ async fn cmd_analytics(
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 @@ async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Ve
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 @@ async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<
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 @@ async fn cmd_promo_create(
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 @@ async fn cmd_blog_list(
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 @@ async fn cmd_blog_list(
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
@@ -8,6 +8,7 @@ mod api;
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 @@ async fn main() -> anyhow::Result<()> {
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");
@@ -0,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 + }
@@ -11,6 +11,7 @@ use russh::{Channel, ChannelId};
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 @@ pub struct MnwHandler {
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 @@ impl MnwHandler {
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 @@ impl russh::server::Handler for MnwHandler {
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 @@ impl russh::server::Handler for MnwHandler {
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 @@ impl russh::server::Handler for MnwHandler {
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 @@ impl russh::server::Handler for MnwHandler {
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;
@@ -10,20 +10,28 @@ use std::path::PathBuf;
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 @@ impl russh::server::Server for MnwServer {
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 }
@@ -642,7 +642,14 @@ pub(super) async fn handle_item_input(
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 @@ pub(super) async fn handle_blog_input(
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 @@ pub(super) async fn handle_promo_input(
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 @@ pub(super) async fn handle_keys_input(
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();