1use alloc::format;
34use alloc::string::String;
35use alloc::vec::Vec;
36
37pub const DEFAULT_MAX_STRING_LENGTH: usize = 1024 * 1024;
39
40pub const DEFAULT_MAX_PARAM_NAME_LENGTH: usize = 256;
42
43pub const DEFAULT_MAX_URI_LENGTH: usize = 8192;
45
46pub const DEFAULT_MAX_PARAMS: usize = 100;
48
49#[derive(Debug, Clone, Copy)]
53pub struct InputLimits {
54 pub max_string_length: usize,
56 pub max_param_name_length: usize,
58 pub max_uri_length: usize,
60 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 #[must_use]
78 pub const fn production() -> Self {
79 Self {
80 max_string_length: 64 * 1024, max_param_name_length: 128,
82 max_uri_length: 2048,
83 max_params: 50,
84 }
85 }
86
87 #[must_use]
89 pub const fn development() -> Self {
90 Self {
91 max_string_length: 10 * 1024 * 1024, max_param_name_length: 512,
93 max_uri_length: 65536,
94 max_params: 1000,
95 }
96 }
97
98 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum InputValidationError {
146 StringTooLong {
148 actual: usize,
150 max: usize,
152 },
153 ParamNameTooLong {
155 actual: usize,
157 max: usize,
159 },
160 UriTooLong {
162 actual: usize,
164 max: usize,
166 },
167 TooManyParams {
169 actual: usize,
171 max: usize,
173 },
174 DangerousUriScheme {
176 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
207pub const DANGEROUS_URI_SCHEMES: &[&str] = &["javascript", "vbscript"];
216
217pub 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#[must_use]
267pub fn sanitize_error_message(message: &str) -> String {
268 let mut result = String::from(message);
269
270 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
281fn 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
307fn 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 if let Some(at_pos) = rest.find('@') {
318 let slash_pos = rest.find('/').unwrap_or(rest.len());
319 if at_pos < slash_pos {
320 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 break;
332 }
333 }
334 result
335}
336
337fn 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 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 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 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 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
406fn 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 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 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 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
461fn sanitize_file_paths(s: &str) -> String {
463 let mut result = String::from(s);
464
465 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 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 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 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 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 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
525fn sanitize_emails(s: &str) -> String {
527 let mut result = String::from(s);
528
529 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 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 if start >= abs_at {
543 i = abs_at + 1;
544 continue;
545 }
546
547 let after_at = abs_at + 1;
549 if after_at >= result.len() {
550 break;
551 }
552
553 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 let domain = &result[after_at..end];
561 if domain.contains('.') && domain.len() > 3 {
562 result.replace_range(start..end, "[EMAIL]");
563 i = start + 7; } else {
565 i = end;
566 }
567 } else {
568 break;
569 }
570 }
571
572 result
573}
574
575pub const GENERIC_ERROR_MESSAGE: &str = "An error occurred. Please try again.";
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[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 #[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 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 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 assert_eq!(msg, safe);
712 }
713}