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/// assert_eq!(
120///     sanitize_error_message("File not found: /etc/secrets/key.txt"),
121///     "File not found: [PATH]"
122/// );
123///
124/// assert_eq!(
125///     sanitize_error_message("Connection failed to 192.168.1.100:5432"),
126///     "Connection failed to [IP]:5432"
127/// );
128/// ```
129pub fn sanitize_error_message(message: &str) -> String {
130    let mut sanitized = message.to_string();
131
132    // IMPORTANT: Order matters! Connection strings and URLs must be sanitized
133    // BEFORE IP addresses and file paths, otherwise they get broken up.
134
135    // 1. Sanitize connection strings (database URLs, etc.) - FIRST!
136    sanitized = sanitize_connection_strings(&sanitized);
137
138    // 2. Sanitize URLs - SECOND (before IP addresses)
139    sanitized = sanitize_urls(&sanitized);
140
141    // 3. Sanitize secrets (API keys, tokens, etc.)
142    sanitized = sanitize_secrets(&sanitized);
143
144    // 4. Sanitize IP addresses (IPv4 and IPv6)
145    sanitized = sanitize_ip_addresses(&sanitized);
146
147    // 5. Sanitize file paths (both Unix and Windows)
148    sanitized = sanitize_file_paths(&sanitized);
149
150    // 6. Sanitize email addresses
151    sanitized = sanitize_email_addresses(&sanitized);
152
153    sanitized
154}
155
156/// Sanitize Unix and Windows file paths
157fn sanitize_file_paths(message: &str) -> String {
158    static UNIX_PATH_RE: OnceLock<Regex> = OnceLock::new();
159    static WINDOWS_PATH_RE: OnceLock<Regex> = OnceLock::new();
160
161    // Unix paths: /path/to/file or ./relative/path
162    let unix_re = UNIX_PATH_RE.get_or_init(|| Regex::new(r"(?:/|\./)[\w\-./]+(?:\.\w+)?").unwrap());
163
164    // Windows paths: C:\path\to\file or \\network\share
165    let windows_re = WINDOWS_PATH_RE
166        .get_or_init(|| Regex::new(r"(?:[A-Za-z]:\\|\\\\)[\w\-\\/.]+(?:\.\w+)?").unwrap());
167
168    let mut sanitized = unix_re.replace_all(message, "[PATH]").to_string();
169    sanitized = windows_re.replace_all(&sanitized, "[PATH]").to_string();
170
171    sanitized
172}
173
174/// Sanitize IPv4 and IPv6 addresses
175fn sanitize_ip_addresses(message: &str) -> String {
176    static IPV4_RE: OnceLock<Regex> = OnceLock::new();
177    static IPV6_RE: OnceLock<Regex> = OnceLock::new();
178
179    // IPv4: 192.168.1.1
180    let ipv4_re = IPV4_RE.get_or_init(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap());
181
182    // IPv6: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
183    let ipv6_re = IPV6_RE
184        .get_or_init(|| Regex::new(r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b").unwrap());
185
186    let mut sanitized = ipv4_re.replace_all(message, "[IP]").to_string();
187    sanitized = ipv6_re.replace_all(&sanitized, "[IP]").to_string();
188
189    sanitized
190}
191
192/// Sanitize connection strings (database URLs, etc.)
193fn sanitize_connection_strings(message: &str) -> String {
194    static CONN_STRING_RE: OnceLock<Regex> = OnceLock::new();
195
196    // Match: postgres://user:pass@host:port/db, mysql://..., mongodb://...
197    let conn_re = CONN_STRING_RE.get_or_init(|| {
198        Regex::new(r"\b(?:postgres|mysql|mongodb|redis|amqp|kafka)://[^\s]+").unwrap()
199    });
200
201    conn_re.replace_all(message, "[CONNECTION]").to_string()
202}
203
204/// Sanitize secrets (API keys, tokens, passwords)
205fn sanitize_secrets(message: &str) -> String {
206    static SECRET_RE: OnceLock<Regex> = OnceLock::new();
207
208    // Match: api_key=..., token=..., password=..., secret=..., bearer ...
209    // Note: "key" alone is too generic and causes false positives (e.g., "API key:")
210    // Captures: (key_name)(separator)(value)
211    // Separator can be "=" or ":" or just whitespace (for Bearer tokens)
212    let secret_re = SECRET_RE.get_or_init(|| {
213        Regex::new(r"(?i)\b(api[_-]?key|token|password|secret|bearer)(\s*[=:]?\s*)([^\s,;)]+)")
214            .unwrap()
215    });
216
217    // Normalize output: lowercase keyword + "=" separator for consistency
218    secret_re
219        .replace_all(message, |caps: &regex::Captures| {
220            format!("{}=[REDACTED]", caps[1].to_lowercase())
221        })
222        .to_string()
223}
224
225/// Sanitize email addresses
226fn sanitize_email_addresses(message: &str) -> String {
227    static EMAIL_RE: OnceLock<Regex> = OnceLock::new();
228
229    // Match: user@example.com
230    let email_re = EMAIL_RE.get_or_init(|| {
231        Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap()
232    });
233
234    email_re.replace_all(message, "[EMAIL]").to_string()
235}
236
237/// Sanitize URLs (HTTP/HTTPS)
238fn sanitize_urls(message: &str) -> String {
239    static URL_RE: OnceLock<Regex> = OnceLock::new();
240
241    // Match: http://... or https://...
242    let url_re = URL_RE.get_or_init(|| Regex::new(r"\b(?:https?|ftp)://[^\s]+").unwrap());
243
244    url_re.replace_all(message, "[URL]").to_string()
245}
246
247/// Generic error message for production (OWASP recommendation)
248///
249/// Use this when you want to completely hide error details from users.
250pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again or contact support.";
251
252/// Create a generic error response for production
253pub fn generic_error() -> String {
254    GENERIC_ERROR_MESSAGE.to_string()
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_sanitize_unix_paths() {
263        assert_eq!(
264            sanitize_file_paths("File not found: /etc/secrets/key.txt"),
265            "File not found: [PATH]"
266        );
267        assert_eq!(
268            sanitize_file_paths("Error reading ./config/database.yml"),
269            "Error reading [PATH]"
270        );
271        assert_eq!(
272            sanitize_file_paths("Failed: /home/user/.ssh/id_rsa"),
273            "Failed: [PATH]"
274        );
275    }
276
277    #[test]
278    fn test_sanitize_windows_paths() {
279        assert_eq!(
280            sanitize_file_paths("File not found: C:\\Windows\\System32\\config.sys"),
281            "File not found: [PATH]"
282        );
283        assert_eq!(
284            sanitize_file_paths("Error: \\\\server\\share\\data.txt"),
285            "Error: [PATH]"
286        );
287    }
288
289    #[test]
290    fn test_sanitize_ipv4_addresses() {
291        assert_eq!(
292            sanitize_ip_addresses("Connection to 192.168.1.100 failed"),
293            "Connection to [IP] failed"
294        );
295        assert_eq!(
296            sanitize_ip_addresses("Server: 10.0.0.1:8080"),
297            "Server: [IP]:8080"
298        );
299    }
300
301    #[test]
302    fn test_sanitize_ipv6_addresses() {
303        assert_eq!(
304            sanitize_ip_addresses("Failed: 2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
305            "Failed: [IP]"
306        );
307    }
308
309    #[test]
310    fn test_sanitize_connection_strings() {
311        assert_eq!(
312            sanitize_connection_strings("Connect failed: postgres://user:pass@localhost:5432/db"),
313            "Connect failed: [CONNECTION]"
314        );
315        assert_eq!(
316            sanitize_connection_strings("Error: mongodb://admin:secret@cluster.example.com/mydb"),
317            "Error: [CONNECTION]"
318        );
319    }
320
321    #[test]
322    fn test_sanitize_secrets() {
323        assert_eq!(
324            sanitize_secrets("API key: api_key=sk_test_1234567890abcdef"),
325            "API key: api_key=[REDACTED]"
326        );
327        assert_eq!(
328            sanitize_secrets("Auth failed: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"),
329            "Auth failed: token=[REDACTED]"
330        );
331        assert_eq!(
332            sanitize_secrets("Login: password=MySecretPass123"),
333            "Login: password=[REDACTED]"
334        );
335        assert_eq!(
336            sanitize_secrets("Header: Authorization: Bearer abc123"),
337            "Header: Authorization: bearer=[REDACTED]"
338        );
339    }
340
341    #[test]
342    fn test_sanitize_email_addresses() {
343        assert_eq!(
344            sanitize_email_addresses("User: admin@example.com"),
345            "User: [EMAIL]"
346        );
347        assert_eq!(
348            sanitize_email_addresses("Contact: support@company.org"),
349            "Contact: [EMAIL]"
350        );
351    }
352
353    #[test]
354    fn test_sanitize_urls() {
355        assert_eq!(
356            sanitize_urls("Failed to fetch: https://api.example.com/v1/users"),
357            "Failed to fetch: [URL]"
358        );
359        assert_eq!(
360            sanitize_urls("Error: http://internal-service.local/health"),
361            "Error: [URL]"
362        );
363    }
364
365    #[test]
366    fn test_full_sanitization() {
367        let message = "Connection to postgres://admin:pass@192.168.1.100:5432/db failed. \
368                       Check /etc/database/config.yml and contact support@company.com. \
369                       API key: api_key=sk_live_abc123";
370
371        let sanitized = sanitize_error_message(message);
372
373        // Should not contain any sensitive info
374        assert!(!sanitized.contains("postgres://"));
375        assert!(!sanitized.contains("admin:pass"));
376        assert!(!sanitized.contains("192.168.1.100"));
377        assert!(!sanitized.contains("/etc/database"));
378        assert!(!sanitized.contains("support@company.com"));
379        assert!(!sanitized.contains("sk_live_abc123"));
380
381        // Should contain redacted markers
382        assert!(sanitized.contains("[CONNECTION]"));
383        assert!(sanitized.contains("[PATH]"));
384        assert!(sanitized.contains("[EMAIL]"));
385        assert!(sanitized.contains("[REDACTED]"));
386    }
387
388    #[test]
389    fn test_sanitized_error_production_mode() {
390        let error = std::io::Error::new(
391            std::io::ErrorKind::NotFound,
392            "File not found: /etc/secrets/api_key.txt",
393        );
394
395        let sanitized = SanitizedError::production(error);
396        let display = format!("{}", sanitized);
397
398        assert!(!display.contains("/etc/secrets"));
399        assert!(display.contains("[PATH]"));
400    }
401
402    #[test]
403    fn test_sanitized_error_development_mode() {
404        let error = std::io::Error::new(
405            std::io::ErrorKind::NotFound,
406            "File not found: /etc/secrets/api_key.txt",
407        );
408
409        let sanitized = SanitizedError::development(error);
410        let display = format!("{}", sanitized);
411
412        // In development mode, should show full details
413        assert!(display.contains("/etc/secrets/api_key.txt"));
414    }
415
416    #[test]
417    fn test_display_mode_default() {
418        // Default should be production for safety
419        assert_eq!(DisplayMode::default(), DisplayMode::Production);
420    }
421
422    #[test]
423    fn test_generic_error_message() {
424        let msg = generic_error();
425        assert_eq!(msg, GENERIC_ERROR_MESSAGE);
426        assert!(msg.contains("An error occurred"));
427    }
428
429    #[test]
430    fn test_no_false_positives() {
431        // Should not sanitize normal text
432        let message = "User 123 requested tool list";
433        assert_eq!(sanitize_error_message(message), message);
434
435        // Should not sanitize port numbers
436        let message = "Server running on port 8080";
437        assert_eq!(sanitize_error_message(message), message);
438    }
439}