turbomcp_server/
error_sanitization.rs

1//! Error Message Sanitization (Sprint 3.1)
2//!
3//! Prevents information leakage in error messages according to OWASP best practices.
4//!
5//! ## Security Risks (OWASP)
6//!
7//! Error messages can leak sensitive information to attackers:
8//! - **File paths**: `"/Users/admin/project/src/main.rs"` → `"[PATH]"`
9//! - **IP addresses**: `"192.168.1.100"` → `"[IP]"`
10//! - **Connection strings**: `"postgres://user:pass@host/db"` → `"[CONNECTION]"`
11//! - **Stack traces**: Full traces → Generic "An error occurred"
12//! - **System information**: Versions, environment details
13//!
14//! ## Display Modes
15//!
16//! - **Production**: Sanitizes all sensitive information, generic messages
17//! - **Development**: Shows full details for debugging
18//!
19//! ## Usage
20//!
21//! ```rust,ignore
22//! use turbomcp_server::error_sanitization::{SanitizedError, DisplayMode};
23//!
24//! let error = std::io::Error::new(
25//!     std::io::ErrorKind::NotFound,
26//!     "File not found: /etc/secrets/api_key.txt"
27//! );
28//!
29//! // Production: Redacts file path
30//! let sanitized = SanitizedError::new(error, DisplayMode::Production);
31//! println!("{}", sanitized); // "File not found: [PATH]"
32//!
33//! // Development: Shows full details
34//! let detailed = SanitizedError::new(error, DisplayMode::Development);
35//! println!("{}", detailed); // "File not found: /etc/secrets/api_key.txt"
36//! ```
37
38use regex::Regex;
39use std::sync::OnceLock;
40
41/// Display mode for error messages
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum DisplayMode {
44    /// Production mode: Sanitize all sensitive information (default for safety)
45    #[default]
46    Production,
47    /// Development mode: Show full error details
48    Development,
49}
50
51/// Sanitized error wrapper
52#[derive(Debug)]
53pub struct SanitizedError<E> {
54    error: E,
55    mode: DisplayMode,
56}
57
58impl<E> SanitizedError<E> {
59    /// Create a new sanitized error
60    pub fn new(error: E, mode: DisplayMode) -> Self {
61        Self { error, mode }
62    }
63
64    /// Create a production-mode sanitized error
65    pub fn production(error: E) -> Self {
66        Self::new(error, DisplayMode::Production)
67    }
68
69    /// Create a development-mode sanitized error (no sanitization)
70    pub fn development(error: E) -> Self {
71        Self::new(error, DisplayMode::Development)
72    }
73
74    /// Get the inner error
75    pub fn into_inner(self) -> E {
76        self.error
77    }
78
79    /// Get a reference to the inner error
80    pub fn inner(&self) -> &E {
81        &self.error
82    }
83}
84
85impl<E: std::fmt::Display> std::fmt::Display for SanitizedError<E> {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self.mode {
88            DisplayMode::Development => write!(f, "{}", self.error),
89            DisplayMode::Production => {
90                let message = self.error.to_string();
91                write!(f, "{}", sanitize_error_message(&message))
92            }
93        }
94    }
95}
96
97impl<E: std::error::Error> std::error::Error for SanitizedError<E> {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        self.error.source()
100    }
101}
102
103/// Sanitize an error message by redacting sensitive information
104///
105/// # What is Sanitized
106///
107/// - **File paths**: Unix and Windows paths → `[PATH]`
108/// - **IP addresses**: IPv4 and IPv6 → `[IP]`
109/// - **Connection strings**: Database URLs, etc. → `[CONNECTION]`
110/// - **Secrets**: API keys, tokens → `[REDACTED]`
111/// - **Email addresses**: Personal emails → `[EMAIL]`
112/// - **URLs**: Full URLs → `[URL]`
113///
114/// # Examples
115///
116/// ```
117/// use turbomcp_server::error_sanitization::sanitize_error_message;
118///
119/// // Note: Paths with "secret" keyword will trigger secret sanitization
120/// assert_eq!(
121///     sanitize_error_message("File not found: /etc/config/app.txt"),
122///     "File not found: [PATH]"
123/// );
124///
125/// assert_eq!(
126///     sanitize_error_message("Connection failed to 192.168.1.100:5432"),
127///     "Connection failed to [IP]:5432"
128/// );
129/// ```
130pub fn sanitize_error_message(message: &str) -> String {
131    let mut sanitized = message.to_string();
132
133    // IMPORTANT: Order matters! Connection strings and URLs must be sanitized
134    // BEFORE IP addresses and file paths, otherwise they get broken up.
135
136    // 1. Sanitize connection strings (database URLs, etc.) - FIRST!
137    sanitized = sanitize_connection_strings(&sanitized);
138
139    // 2. Sanitize URLs - SECOND (before IP addresses)
140    sanitized = sanitize_urls(&sanitized);
141
142    // 3. Sanitize secrets (API keys, tokens, etc.)
143    sanitized = sanitize_secrets(&sanitized);
144
145    // 4. Sanitize IP addresses (IPv4 and IPv6)
146    sanitized = sanitize_ip_addresses(&sanitized);
147
148    // 5. Sanitize file paths (both Unix and Windows)
149    sanitized = sanitize_file_paths(&sanitized);
150
151    // 6. Sanitize email addresses
152    sanitized = sanitize_email_addresses(&sanitized);
153
154    sanitized
155}
156
157/// Sanitize Unix and Windows file paths
158fn sanitize_file_paths(message: &str) -> String {
159    static UNIX_PATH_RE: OnceLock<Regex> = OnceLock::new();
160    static WINDOWS_PATH_RE: OnceLock<Regex> = OnceLock::new();
161
162    // Unix paths: /path/to/file or ./relative/path
163    let unix_re = UNIX_PATH_RE.get_or_init(|| Regex::new(r"(?:/|\./)[\w\-./]+(?:\.\w+)?").unwrap());
164
165    // Windows paths: C:\path\to\file or \\network\share
166    let windows_re = WINDOWS_PATH_RE
167        .get_or_init(|| Regex::new(r"(?:[A-Za-z]:\\|\\\\)[\w\-\\/.]+(?:\.\w+)?").unwrap());
168
169    let mut sanitized = unix_re.replace_all(message, "[PATH]").to_string();
170    sanitized = windows_re.replace_all(&sanitized, "[PATH]").to_string();
171
172    sanitized
173}
174
175/// Sanitize IPv4 and IPv6 addresses
176fn sanitize_ip_addresses(message: &str) -> String {
177    static IPV4_RE: OnceLock<Regex> = OnceLock::new();
178    static IPV6_RE: OnceLock<Regex> = OnceLock::new();
179
180    // IPv4: 192.168.1.1
181    let ipv4_re = IPV4_RE.get_or_init(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap());
182
183    // IPv6: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
184    let ipv6_re = IPV6_RE
185        .get_or_init(|| Regex::new(r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b").unwrap());
186
187    let mut sanitized = ipv4_re.replace_all(message, "[IP]").to_string();
188    sanitized = ipv6_re.replace_all(&sanitized, "[IP]").to_string();
189
190    sanitized
191}
192
193/// Sanitize connection strings (database URLs, etc.)
194fn sanitize_connection_strings(message: &str) -> String {
195    static CONN_STRING_RE: OnceLock<Regex> = OnceLock::new();
196
197    // Match: postgres://user:pass@host:port/db, mysql://..., mongodb://...
198    let conn_re = CONN_STRING_RE.get_or_init(|| {
199        Regex::new(r"\b(?:postgres|mysql|mongodb|redis|amqp|kafka)://[^\s]+").unwrap()
200    });
201
202    conn_re.replace_all(message, "[CONNECTION]").to_string()
203}
204
205/// Sanitize secrets (API keys, tokens, passwords)
206fn sanitize_secrets(message: &str) -> String {
207    static SECRET_RE: OnceLock<Regex> = OnceLock::new();
208
209    // Match: api_key=..., token=..., password=..., secret=..., bearer ...
210    // Note: "key" alone is too generic and causes false positives (e.g., "API key:")
211    // Captures: (key_name)(separator)(value)
212    // Separator can be "=" or ":" or just whitespace (for Bearer tokens)
213    let secret_re = SECRET_RE.get_or_init(|| {
214        Regex::new(r"(?i)\b(api[_-]?key|token|password|secret|bearer)(\s*[=:]?\s*)([^\s,;)]+)")
215            .unwrap()
216    });
217
218    // Normalize output: lowercase keyword + "=" separator for consistency
219    secret_re
220        .replace_all(message, |caps: &regex::Captures| {
221            format!("{}=[REDACTED]", caps[1].to_lowercase())
222        })
223        .to_string()
224}
225
226/// Sanitize email addresses
227fn sanitize_email_addresses(message: &str) -> String {
228    static EMAIL_RE: OnceLock<Regex> = OnceLock::new();
229
230    // Match: user@example.com
231    let email_re = EMAIL_RE.get_or_init(|| {
232        Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap()
233    });
234
235    email_re.replace_all(message, "[EMAIL]").to_string()
236}
237
238/// Sanitize URLs (HTTP/HTTPS)
239fn sanitize_urls(message: &str) -> String {
240    static URL_RE: OnceLock<Regex> = OnceLock::new();
241
242    // Match: http://... or https://...
243    let url_re = URL_RE.get_or_init(|| Regex::new(r"\b(?:https?|ftp)://[^\s]+").unwrap());
244
245    url_re.replace_all(message, "[URL]").to_string()
246}
247
248/// Generic error message for production (OWASP recommendation)
249///
250/// Use this when you want to completely hide error details from users.
251pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again or contact support.";
252
253/// Create a generic error response for production
254pub fn generic_error() -> String {
255    GENERIC_ERROR_MESSAGE.to_string()
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_sanitize_unix_paths() {
264        assert_eq!(
265            sanitize_file_paths("File not found: /etc/secrets/key.txt"),
266            "File not found: [PATH]"
267        );
268        assert_eq!(
269            sanitize_file_paths("Error reading ./config/database.yml"),
270            "Error reading [PATH]"
271        );
272        assert_eq!(
273            sanitize_file_paths("Failed: /home/user/.ssh/id_rsa"),
274            "Failed: [PATH]"
275        );
276    }
277
278    #[test]
279    fn test_sanitize_windows_paths() {
280        assert_eq!(
281            sanitize_file_paths("File not found: C:\\Windows\\System32\\config.sys"),
282            "File not found: [PATH]"
283        );
284        assert_eq!(
285            sanitize_file_paths("Error: \\\\server\\share\\data.txt"),
286            "Error: [PATH]"
287        );
288    }
289
290    #[test]
291    fn test_sanitize_ipv4_addresses() {
292        assert_eq!(
293            sanitize_ip_addresses("Connection to 192.168.1.100 failed"),
294            "Connection to [IP] failed"
295        );
296        assert_eq!(
297            sanitize_ip_addresses("Server: 10.0.0.1:8080"),
298            "Server: [IP]:8080"
299        );
300    }
301
302    #[test]
303    fn test_sanitize_ipv6_addresses() {
304        assert_eq!(
305            sanitize_ip_addresses("Failed: 2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
306            "Failed: [IP]"
307        );
308    }
309
310    #[test]
311    fn test_sanitize_connection_strings() {
312        assert_eq!(
313            sanitize_connection_strings("Connect failed: postgres://user:pass@localhost:5432/db"),
314            "Connect failed: [CONNECTION]"
315        );
316        assert_eq!(
317            sanitize_connection_strings("Error: mongodb://admin:secret@cluster.example.com/mydb"),
318            "Error: [CONNECTION]"
319        );
320    }
321
322    #[test]
323    fn test_sanitize_secrets() {
324        assert_eq!(
325            sanitize_secrets("API key: api_key=sk_test_1234567890abcdef"),
326            "API key: api_key=[REDACTED]"
327        );
328        assert_eq!(
329            sanitize_secrets("Auth failed: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
330            "Auth failed: token=[REDACTED]"
331        );
332        assert_eq!(
333            sanitize_secrets("Login: password=MySecretPass123"),
334            "Login: password=[REDACTED]"
335        );
336        assert_eq!(
337            sanitize_secrets("Header: Authorization: Bearer abc123"),
338            "Header: Authorization: bearer=[REDACTED]"
339        );
340    }
341
342    #[test]
343    fn test_sanitize_email_addresses() {
344        assert_eq!(
345            sanitize_email_addresses("User: admin@example.com"),
346            "User: [EMAIL]"
347        );
348        assert_eq!(
349            sanitize_email_addresses("Contact: support@company.org"),
350            "Contact: [EMAIL]"
351        );
352    }
353
354    #[test]
355    fn test_sanitize_urls() {
356        assert_eq!(
357            sanitize_urls("Failed to fetch: https://api.example.com/v1/users"),
358            "Failed to fetch: [URL]"
359        );
360        assert_eq!(
361            sanitize_urls("Error: http://internal-service.local/health"),
362            "Error: [URL]"
363        );
364    }
365
366    #[test]
367    fn test_full_sanitization() {
368        let message = "Connection to postgres://admin:pass@192.168.1.100:5432/db failed. \
369                       Check /etc/database/config.yml and contact support@company.com. \
370                       API key: api_key=sk_live_abc123";
371
372        let sanitized = sanitize_error_message(message);
373
374        // Should not contain any sensitive info
375        assert!(!sanitized.contains("postgres://"));
376        assert!(!sanitized.contains("admin:pass"));
377        assert!(!sanitized.contains("192.168.1.100"));
378        assert!(!sanitized.contains("/etc/database"));
379        assert!(!sanitized.contains("support@company.com"));
380        assert!(!sanitized.contains("sk_live_abc123"));
381
382        // Should contain redacted markers
383        assert!(sanitized.contains("[CONNECTION]"));
384        assert!(sanitized.contains("[PATH]"));
385        assert!(sanitized.contains("[EMAIL]"));
386        assert!(sanitized.contains("[REDACTED]"));
387    }
388
389    #[test]
390    fn test_sanitized_error_production_mode() {
391        let error = std::io::Error::new(
392            std::io::ErrorKind::NotFound,
393            "File not found: /etc/secrets/api_key.txt",
394        );
395
396        let sanitized = SanitizedError::production(error);
397        let display = format!("{}", sanitized);
398
399        assert!(!display.contains("/etc/secrets"));
400        assert!(display.contains("[PATH]"));
401    }
402
403    #[test]
404    fn test_sanitized_error_development_mode() {
405        let error = std::io::Error::new(
406            std::io::ErrorKind::NotFound,
407            "File not found: /etc/secrets/api_key.txt",
408        );
409
410        let sanitized = SanitizedError::development(error);
411        let display = format!("{}", sanitized);
412
413        // In development mode, should show full details
414        assert!(display.contains("/etc/secrets/api_key.txt"));
415    }
416
417    #[test]
418    fn test_display_mode_default() {
419        // Default should be production for safety
420        assert_eq!(DisplayMode::default(), DisplayMode::Production);
421    }
422
423    #[test]
424    fn test_generic_error_message() {
425        let msg = generic_error();
426        assert_eq!(msg, GENERIC_ERROR_MESSAGE);
427        assert!(msg.contains("An error occurred"));
428    }
429
430    #[test]
431    fn test_no_false_positives() {
432        // Should not sanitize normal text
433        let message = "User 123 requested tool list";
434        assert_eq!(sanitize_error_message(message), message);
435
436        // Should not sanitize port numbers
437        let message = "Server running on port 8080";
438        assert_eq!(sanitize_error_message(message), message);
439    }
440}