Skip to main content

turbomcp_core/
security.rs

1//! Security utilities for error sanitization and input validation.
2//!
3//! This module provides `no_std` compatible security primitives for:
4//! - Error message sanitization (OWASP compliant)
5//! - Input size validation
6//! - URI scheme validation
7//!
8//! ## Error Sanitization
9//!
10//! Production systems should never expose internal details in error messages.
11//! Use [`sanitize_error_message`] to redact sensitive information:
12//!
13//! ```rust
14//! use turbomcp_core::security::sanitize_error_message;
15//!
16//! let unsafe_msg = "Failed to connect to postgres://admin:secret@192.168.1.100:5432/db";
17//! let safe_msg = sanitize_error_message(unsafe_msg);
18//! assert!(!safe_msg.contains("secret"));
19//! assert!(!safe_msg.contains("192.168.1.100"));
20//! ```
21//!
22//! ## Input Validation
23//!
24//! Use [`InputLimits`] to validate parameter sizes:
25//!
26//! ```rust
27//! use turbomcp_core::security::InputLimits;
28//!
29//! let limits = InputLimits::default();
30//! assert!(limits.check_string_length("short").is_ok());
31//! ```
32
33use alloc::format;
34use alloc::string::String;
35use alloc::vec::Vec;
36
37/// Maximum parameter string length (1MB by default)
38pub const DEFAULT_MAX_STRING_LENGTH: usize = 1024 * 1024;
39
40/// Maximum parameter name length
41pub const DEFAULT_MAX_PARAM_NAME_LENGTH: usize = 256;
42
43/// Maximum URI length (8KB - standard browser limit)
44pub const DEFAULT_MAX_URI_LENGTH: usize = 8192;
45
46/// Maximum number of parameters per tool call
47pub const DEFAULT_MAX_PARAMS: usize = 100;
48
49/// Configuration for input validation limits.
50///
51/// Use this to enforce size constraints on user input to prevent DoS attacks.
52#[derive(Debug, Clone, Copy)]
53pub struct InputLimits {
54    /// Maximum string parameter length in bytes
55    pub max_string_length: usize,
56    /// Maximum parameter name length
57    pub max_param_name_length: usize,
58    /// Maximum URI length
59    pub max_uri_length: usize,
60    /// Maximum number of parameters
61    pub max_params: usize,
62}
63
64impl Default for InputLimits {
65    fn default() -> Self {
66        Self {
67            max_string_length: DEFAULT_MAX_STRING_LENGTH,
68            max_param_name_length: DEFAULT_MAX_PARAM_NAME_LENGTH,
69            max_uri_length: DEFAULT_MAX_URI_LENGTH,
70            max_params: DEFAULT_MAX_PARAMS,
71        }
72    }
73}
74
75impl InputLimits {
76    /// Create limits for production use (stricter)
77    #[must_use]
78    pub const fn production() -> Self {
79        Self {
80            max_string_length: 64 * 1024, // 64KB
81            max_param_name_length: 128,
82            max_uri_length: 2048,
83            max_params: 50,
84        }
85    }
86
87    /// Create limits for development (more permissive)
88    #[must_use]
89    pub const fn development() -> Self {
90        Self {
91            max_string_length: 10 * 1024 * 1024, // 10MB
92            max_param_name_length: 512,
93            max_uri_length: 65536,
94            max_params: 1000,
95        }
96    }
97
98    /// Check if a string parameter is within limits
99    pub fn check_string_length(&self, s: &str) -> Result<(), InputValidationError> {
100        if s.len() > self.max_string_length {
101            return Err(InputValidationError::StringTooLong {
102                actual: s.len(),
103                max: self.max_string_length,
104            });
105        }
106        Ok(())
107    }
108
109    /// Check if a parameter name is within limits
110    pub fn check_param_name(&self, name: &str) -> Result<(), InputValidationError> {
111        if name.len() > self.max_param_name_length {
112            return Err(InputValidationError::ParamNameTooLong {
113                actual: name.len(),
114                max: self.max_param_name_length,
115            });
116        }
117        Ok(())
118    }
119
120    /// Check if a URI is within limits
121    pub fn check_uri_length(&self, uri: &str) -> Result<(), InputValidationError> {
122        if uri.len() > self.max_uri_length {
123            return Err(InputValidationError::UriTooLong {
124                actual: uri.len(),
125                max: self.max_uri_length,
126            });
127        }
128        Ok(())
129    }
130
131    /// Check if parameter count is within limits
132    pub fn check_param_count(&self, count: usize) -> Result<(), InputValidationError> {
133        if count > self.max_params {
134            return Err(InputValidationError::TooManyParams {
135                actual: count,
136                max: self.max_params,
137            });
138        }
139        Ok(())
140    }
141}
142
143/// Input validation errors
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum InputValidationError {
146    /// String parameter exceeds maximum length
147    StringTooLong {
148        /// Actual length
149        actual: usize,
150        /// Maximum allowed
151        max: usize,
152    },
153    /// Parameter name exceeds maximum length
154    ParamNameTooLong {
155        /// Actual length
156        actual: usize,
157        /// Maximum allowed
158        max: usize,
159    },
160    /// URI exceeds maximum length
161    UriTooLong {
162        /// Actual length
163        actual: usize,
164        /// Maximum allowed
165        max: usize,
166    },
167    /// Too many parameters
168    TooManyParams {
169        /// Actual count
170        actual: usize,
171        /// Maximum allowed
172        max: usize,
173    },
174    /// URI scheme is on the dangerous denylist (e.g. `javascript:`, `vbscript:`)
175    DangerousUriScheme {
176        /// The scheme that was rejected
177        scheme: String,
178    },
179}
180
181impl core::fmt::Display for InputValidationError {
182    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
183        match self {
184            Self::StringTooLong { actual, max } => {
185                write!(f, "String too long: {} bytes (max: {})", actual, max)
186            }
187            Self::ParamNameTooLong { actual, max } => {
188                write!(
189                    f,
190                    "Parameter name too long: {} bytes (max: {})",
191                    actual, max
192                )
193            }
194            Self::UriTooLong { actual, max } => {
195                write!(f, "URI too long: {} bytes (max: {})", actual, max)
196            }
197            Self::TooManyParams { actual, max } => {
198                write!(f, "Too many parameters: {} (max: {})", actual, max)
199            }
200            Self::DangerousUriScheme { scheme } => {
201                write!(f, "URI scheme is blocked for security: {}", scheme)
202            }
203        }
204    }
205}
206
207/// URI schemes that are always rejected for safety.
208///
209/// The MCP spec (2025-11-25, server/resources) explicitly permits custom URI
210/// schemes, so TurboMCP does **not** enforce an allowlist. This denylist is a
211/// minimal XSS guard covering schemes that are dangerous if a client ever
212/// renders or follows the URI. SSRF protection for URIs that the SDK itself
213/// dereferences (e.g. HTTP fetches in `turbomcp-proxy`) lives in those layers
214/// with per-deployment allowlists.
215pub const DANGEROUS_URI_SCHEMES: &[&str] = &["javascript", "vbscript"];
216
217/// Reject URIs whose scheme is on the dangerous denylist.
218///
219/// Accepts any RFC 3986–shaped scheme that is not explicitly dangerous,
220/// including custom schemes such as `apple-doc://`, `notion://`, or `mcp://`.
221/// Returns the normalized (lowercased) scheme on success, or
222/// [`InputValidationError::DangerousUriScheme`] if the scheme is blocked.
223///
224/// Scheme extraction handles both `scheme://authority/...` and
225/// `scheme:opaque` forms (RFC 3986 §3.1). Schemes are compared case
226/// insensitively because URI schemes are canonically lowercase.
227pub fn check_uri_scheme_safety(uri: &str) -> Result<String, InputValidationError> {
228    let scheme = if let Some(pos) = uri.find("://") {
229        &uri[..pos]
230    } else if let Some(pos) = uri.find(':') {
231        &uri[..pos]
232    } else {
233        ""
234    };
235
236    let normalized: String = scheme.chars().map(|c| c.to_ascii_lowercase()).collect();
237
238    if DANGEROUS_URI_SCHEMES.contains(&normalized.as_str()) {
239        return Err(InputValidationError::DangerousUriScheme { scheme: normalized });
240    }
241
242    Ok(normalized)
243}
244
245/// Sanitize an error message by redacting sensitive information.
246///
247/// This function removes/replaces:
248/// - File paths (Unix and Windows)
249/// - IP addresses (IPv4)
250/// - Connection strings (database URLs)
251/// - Secrets (API keys, tokens, passwords)
252/// - Email addresses
253/// - URLs with credentials
254///
255/// This is a `no_std` compatible implementation without regex.
256///
257/// # Example
258///
259/// ```rust
260/// use turbomcp_core::security::sanitize_error_message;
261///
262/// let msg = "Connection failed to postgres://user:pass@localhost:5432/db";
263/// let safe = sanitize_error_message(msg);
264/// assert!(!safe.contains("pass"));
265/// ```
266#[must_use]
267pub fn sanitize_error_message(message: &str) -> String {
268    let mut result = String::from(message);
269
270    // Sanitize in order of specificity (most specific patterns first)
271    result = sanitize_connection_strings(&result);
272    result = sanitize_urls_with_credentials(&result);
273    result = sanitize_secrets(&result);
274    result = sanitize_ip_addresses(&result);
275    result = sanitize_file_paths(&result);
276    result = sanitize_emails(&result);
277
278    result
279}
280
281/// Sanitize connection strings (database URLs)
282fn sanitize_connection_strings(s: &str) -> String {
283    let prefixes = [
284        "postgres://",
285        "postgresql://",
286        "mysql://",
287        "mongodb://",
288        "redis://",
289        "amqp://",
290        "kafka://",
291        "sqlite://",
292    ];
293
294    let mut result = String::from(s);
295    for prefix in prefixes {
296        while let Some(start) = result.find(prefix) {
297            let end = result[start..]
298                .find(|c: char| c.is_whitespace() || c == '\'' || c == '"')
299                .map(|i| start + i)
300                .unwrap_or(result.len());
301            result.replace_range(start..end, "[CONNECTION]");
302        }
303    }
304    result
305}
306
307/// Sanitize URLs containing credentials (user:pass@host)
308fn sanitize_urls_with_credentials(s: &str) -> String {
309    let mut result = String::from(s);
310
311    for prefix in ["http://", "https://", "ftp://"] {
312        while let Some(start) = result.find(prefix) {
313            let after_proto = start + prefix.len();
314            let rest = &result[after_proto..];
315
316            // Check if this URL has credentials (contains @ before /)
317            if let Some(at_pos) = rest.find('@') {
318                let slash_pos = rest.find('/').unwrap_or(rest.len());
319                if at_pos < slash_pos {
320                    // This URL has credentials - redact them
321                    let end = result[start..]
322                        .find(|c: char| c.is_whitespace() || c == '\'' || c == '"')
323                        .map(|i| start + i)
324                        .unwrap_or(result.len());
325                    result.replace_range(start..end, "[URL]");
326                    continue;
327                }
328            }
329
330            // No credentials, skip this occurrence
331            break;
332        }
333    }
334    result
335}
336
337/// Sanitize secret patterns (api_key=..., password=..., token=..., etc.)
338fn sanitize_secrets(s: &str) -> String {
339    let patterns = [
340        "api_key=",
341        "api-key=",
342        "apikey=",
343        "password=",
344        "passwd=",
345        "token=",
346        "secret=",
347        "api_key:",
348        "api-key:",
349        "password:",
350        "token:",
351        "secret:",
352        "Bearer ",
353        "bearer ",
354        "Authorization: ",
355    ];
356
357    let mut result = String::from(s);
358
359    for pattern in patterns {
360        // Found afresh in the current text for every pattern: offsets taken
361        // from the original went stale as soon as an earlier pattern had
362        // shortened or lengthened the text, and then cut replacements in the
363        // wrong place — `password=ab token=cd` came out with `cd` intact.
364        //
365        // ASCII lowercasing, because it keeps byte offsets aligned with
366        // `result`. `to_lowercase` does not ('İ' lowercases to two chars), and
367        // its offsets sliced through a character and panicked.
368        let lower = result.to_ascii_lowercase();
369        let pattern_lower = pattern.to_ascii_lowercase();
370        let positions: Vec<usize> = lower
371            .match_indices(pattern_lower.as_str())
372            .map(|(at, _)| at)
373            .collect();
374
375        // Back to front, so each replacement leaves earlier offsets valid.
376        for start in positions.into_iter().rev() {
377            let prefix_end = start + pattern.len();
378            if prefix_end >= result.len() {
379                continue;
380            }
381
382            // Find end of secret value (whitespace, comma, semicolon, quote, or end)
383            let end = result[prefix_end..]
384                .find(|c: char| {
385                    c.is_whitespace() || c == ',' || c == ';' || c == '\'' || c == '"' || c == ')'
386                })
387                .map(|i| prefix_end + i)
388                .unwrap_or(result.len());
389
390            // Build replacement string (preserve original case of keyword)
391            let keyword = &result[start..prefix_end];
392            let replacement = if keyword.ends_with('=') {
393                format!("{}=[REDACTED]", keyword.trim_end_matches('='))
394            } else if keyword.ends_with(':') {
395                format!("{}:[REDACTED]", keyword.trim_end_matches(':'))
396            } else {
397                format!("{} [REDACTED]", keyword.trim())
398            };
399
400            result.replace_range(start..end, &replacement);
401        }
402    }
403    result
404}
405
406/// Sanitize IPv4 addresses
407fn sanitize_ip_addresses(s: &str) -> String {
408    let mut result = String::new();
409    let mut chars = s.chars().peekable();
410
411    while let Some(c) = chars.next() {
412        // Check if this might be start of an IP address
413        if c.is_ascii_digit() {
414            let mut potential_ip = String::from(c);
415            let mut dot_count = 0;
416            let mut is_ip = true;
417            let mut segment_digits = 1;
418
419            // Try to collect an IP address
420            while let Some(&next) = chars.peek() {
421                if next.is_ascii_digit() {
422                    segment_digits += 1;
423                    if segment_digits > 3 {
424                        is_ip = false;
425                        break;
426                    }
427                    potential_ip.push(chars.next().unwrap());
428                } else if next == '.' && dot_count < 3 {
429                    dot_count += 1;
430                    segment_digits = 0;
431                    potential_ip.push(chars.next().unwrap());
432                } else {
433                    break;
434                }
435            }
436
437            // Validate: must have exactly 3 dots and valid segments
438            if is_ip && dot_count == 3 {
439                let segments: Vec<&str> = potential_ip.split('.').collect();
440                let valid_ip = segments.len() == 4
441                    && segments
442                        .iter()
443                        .all(|seg| !seg.is_empty() && seg.len() <= 3 && seg.parse::<u8>().is_ok());
444
445                if valid_ip {
446                    result.push_str("[IP]");
447                } else {
448                    result.push_str(&potential_ip);
449                }
450            } else {
451                result.push_str(&potential_ip);
452            }
453        } else {
454            result.push(c);
455        }
456    }
457
458    result
459}
460
461/// Sanitize file paths (Unix and Windows)
462fn sanitize_file_paths(s: &str) -> String {
463    let mut result = String::from(s);
464
465    // Unix absolute paths: /path/to/file
466    let mut i = 0;
467    while i < result.len() {
468        let bytes = result.as_bytes();
469        if bytes[i] == b'/' && (i == 0 || !bytes[i - 1].is_ascii_alphanumeric()) {
470            // Check if this looks like a path (has more path chars after /)
471            let rest = &result[i..];
472            if rest.len() > 1
473                && (rest
474                    .chars()
475                    .nth(1)
476                    .is_some_and(|c| c.is_alphanumeric() || c == '.'))
477            {
478                // Find end of path
479                let end = rest[1..]
480                    .find(|c: char| {
481                        c.is_whitespace() || c == '\'' || c == '"' || c == ')' || c == ']'
482                    })
483                    .map(|p| i + 1 + p)
484                    .unwrap_or(result.len());
485
486                // Only redact if it looks like a real path (has / or extension)
487                let path_segment = &result[i..end];
488                if path_segment.contains('/')
489                    || (path_segment.contains('.') && path_segment.len() > 4)
490                {
491                    result.replace_range(i..end, "[PATH]");
492                } else {
493                    i += 1;
494                }
495            } else {
496                i += 1;
497            }
498        } else {
499            i += 1;
500        }
501    }
502
503    // Windows paths: C:\path\to\file
504    let mut i = 0;
505    while i < result.len() {
506        let bytes = result.as_bytes();
507        if i + 2 < bytes.len()
508            && bytes[i].is_ascii_alphabetic()
509            && bytes[i + 1] == b':'
510            && bytes[i + 2] == b'\\'
511        {
512            // Find end of Windows path
513            let end = result[i..]
514                .find(|c: char| c.is_whitespace() || c == '\'' || c == '"')
515                .map(|p| i + p)
516                .unwrap_or(result.len());
517            result.replace_range(i..end, "[PATH]");
518        }
519        i += 1;
520    }
521
522    result
523}
524
525/// Sanitize email addresses
526fn sanitize_emails(s: &str) -> String {
527    let mut result = String::from(s);
528
529    // Simple email detection: look for @ followed by domain
530    let mut i = 0;
531    while i < result.len() {
532        if let Some(at_pos) = result[i..].find('@') {
533            let abs_at = i + at_pos;
534
535            // Find start of email (username part)
536            let start = result[..abs_at]
537                .rfind(|c: char| c.is_whitespace() || c == '<' || c == '(' || c == ',')
538                .map(|p| p + 1)
539                .unwrap_or(0);
540
541            // Validate we have a username
542            if start >= abs_at {
543                i = abs_at + 1;
544                continue;
545            }
546
547            // Find end of email (domain part)
548            let after_at = abs_at + 1;
549            if after_at >= result.len() {
550                break;
551            }
552
553            // Find end of domain
554            let end = result[after_at..]
555                .find(|c: char| c.is_whitespace() || c == '>' || c == ')' || c == ',')
556                .map(|p| after_at + p)
557                .unwrap_or(result.len());
558
559            // Validate domain has a dot
560            let domain = &result[after_at..end];
561            if domain.contains('.') && domain.len() > 3 {
562                result.replace_range(start..end, "[EMAIL]");
563                i = start + 7; // length of "[EMAIL]"
564            } else {
565                i = end;
566            }
567        } else {
568            break;
569        }
570    }
571
572    result
573}
574
575/// Generic safe error message for production.
576pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again.";
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    /// Two secrets in one message used to leave the second in place, because
583    /// offsets were taken before the first was replaced.
584    #[test]
585    fn test_sanitize_every_secret_in_a_message() {
586        let safe = sanitize_error_message("password=ab token=cd");
587        assert!(!safe.contains("ab"), "{safe}");
588        assert!(!safe.contains("cd"), "{safe}");
589    }
590
591    /// Characters whose lowercase form is longer used to shift the offsets
592    /// into the middle of a character, and slicing there panicked.
593    #[test]
594    fn test_sanitize_non_ascii_does_not_panic() {
595        let safe = sanitize_error_message("İtoken=é secret");
596        assert!(!safe.contains('é'), "{safe}");
597    }
598
599    #[test]
600    fn test_sanitize_connection_strings() {
601        let msg = "Failed: postgres://admin:secret@localhost:5432/mydb";
602        let safe = sanitize_error_message(msg);
603        assert!(!safe.contains("admin"));
604        assert!(!safe.contains("secret"));
605        assert!(safe.contains("[CONNECTION]"));
606    }
607
608    #[test]
609    fn test_sanitize_ip_addresses() {
610        let msg = "Connection to 192.168.1.100:5432 failed";
611        let safe = sanitize_error_message(msg);
612        assert!(!safe.contains("192.168.1.100"));
613        assert!(safe.contains("[IP]"));
614    }
615
616    #[test]
617    fn test_sanitize_file_paths() {
618        let msg = "File not found: /etc/secrets/api_key.txt";
619        let safe = sanitize_error_message(msg);
620        assert!(!safe.contains("/etc/secrets"));
621        assert!(safe.contains("[PATH]"));
622    }
623
624    #[test]
625    fn test_sanitize_secrets() {
626        let msg = "Auth failed: api_key=sk_live_abc123xyz";
627        let safe = sanitize_error_message(msg);
628        assert!(!safe.contains("sk_live"));
629        assert!(safe.contains("[REDACTED]"));
630    }
631
632    #[test]
633    fn test_sanitize_emails() {
634        let msg = "User admin@example.com not found";
635        let safe = sanitize_error_message(msg);
636        assert!(!safe.contains("admin@example.com"));
637        assert!(safe.contains("[EMAIL]"));
638    }
639
640    #[test]
641    fn test_input_limits() {
642        let limits = InputLimits::production();
643        assert!(limits.check_string_length("short").is_ok());
644
645        let long_string = "x".repeat(limits.max_string_length + 1);
646        assert!(limits.check_string_length(&long_string).is_err());
647    }
648
649    #[test]
650    fn test_uri_scheme_safety_accepts_standard_schemes() {
651        assert!(check_uri_scheme_safety("file:///etc/passwd").is_ok());
652        assert!(check_uri_scheme_safety("https://example.com").is_ok());
653        assert!(check_uri_scheme_safety("data:text/html,hello").is_ok());
654        assert!(check_uri_scheme_safety("mcp://server/resource").is_ok());
655    }
656
657    #[test]
658    fn test_uri_scheme_safety_accepts_custom_schemes() {
659        // Per MCP spec (2025-11-25), implementations MUST allow custom schemes.
660        assert_eq!(
661            check_uri_scheme_safety("apple-doc://swift/StringProtocol").unwrap(),
662            "apple-doc"
663        );
664        assert_eq!(
665            check_uri_scheme_safety("notion://workspace/page/abc123").unwrap(),
666            "notion"
667        );
668        assert_eq!(
669            check_uri_scheme_safety("slack://workspace/C01234567").unwrap(),
670            "slack"
671        );
672        assert_eq!(
673            check_uri_scheme_safety("weather://api/current").unwrap(),
674            "weather"
675        );
676        assert_eq!(
677            check_uri_scheme_safety("custom+scheme://data").unwrap(),
678            "custom+scheme"
679        );
680    }
681
682    #[test]
683    fn test_uri_scheme_safety_rejects_dangerous_schemes() {
684        assert!(matches!(
685            check_uri_scheme_safety("javascript:alert(1)"),
686            Err(InputValidationError::DangerousUriScheme { .. })
687        ));
688        assert!(matches!(
689            check_uri_scheme_safety("vbscript:msgbox(\"xss\")"),
690            Err(InputValidationError::DangerousUriScheme { .. })
691        ));
692    }
693
694    #[test]
695    fn test_uri_scheme_safety_is_case_insensitive() {
696        // RFC 3986 §3.1: schemes are case-insensitive, canonical form is lowercase.
697        assert!(check_uri_scheme_safety("JavaScript:alert(1)").is_err());
698        assert!(check_uri_scheme_safety("JAVASCRIPT:alert(1)").is_err());
699        assert!(check_uri_scheme_safety("VBScript:msgbox(1)").is_err());
700        assert_eq!(
701            check_uri_scheme_safety("HTTPS://example.com").unwrap(),
702            "https"
703        );
704    }
705
706    #[test]
707    fn test_no_false_positives() {
708        let msg = "User 123 requested tool list on port 8080";
709        let safe = sanitize_error_message(msg);
710        // Should not sanitize normal numbers or port references
711        assert_eq!(msg, safe);
712    }
713}