Skip to content

Commit 33e7e34

Browse files
committed
fix(security): consolidated path traversal prevention for MCP and session storage
## Summary This PR consolidates **2 security fixes** for path traversal vulnerabilities. ### Included PRs: - #81: Prevent path traversal in session storage via session_id sanitization - #83: Prevent path traversal in MCP storage via server name sanitization ### Key Changes: - Add sanitize_session_id() function that replaces dangerous characters - Add validate_session_id() for pre-validation of untrusted input - Add sanitize_server_name() function for MCP server names - Add validate_server_name() for pre-validation of MCP server names - Only alphanumeric, hyphen, and underscore characters are allowed ### Files Modified: - src/cortex-tui/src/session/storage.rs - src/cortex-tui/src/mcp_storage.rs Closes #81, #83
1 parent c398212 commit 33e7e34

File tree

2 files changed

+161
-2
lines changed

2 files changed

+161
-2
lines changed

src/cortex-tui/src/mcp_storage.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@ use anyhow::{Context, Result};
1515
use cortex_common::AppDirs;
1616
use serde::{Deserialize, Serialize};
1717

18+
// ============================================================
19+
// SECURITY HELPERS
20+
// ============================================================
21+
22+
/// Sanitize a server name to prevent path traversal attacks.
23+
///
24+
/// Only allows alphanumeric characters, hyphens, and underscores.
25+
/// Any other characters (including path separators) are replaced with underscores.
26+
fn sanitize_server_name(name: &str) -> String {
27+
name.chars()
28+
.map(|c| {
29+
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
30+
c
31+
} else {
32+
'_'
33+
}
34+
})
35+
.collect()
36+
}
37+
38+
/// Validate a server name for safe filesystem use.
39+
///
40+
/// Returns true if the name contains only safe characters.
41+
#[allow(dead_code)]
42+
pub fn validate_server_name(name: &str) -> bool {
43+
!name.is_empty()
44+
&& name
45+
.chars()
46+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
47+
}
48+
1849
/// MCP transport type
1950
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2051
#[serde(rename_all = "lowercase")]
@@ -158,8 +189,11 @@ impl McpStorage {
158189
}
159190

160191
/// Get the path to a server's config file
192+
///
193+
/// The server name is sanitized to prevent path traversal attacks.
161194
fn server_path(&self, name: &str) -> PathBuf {
162-
self.mcps_dir.join(format!("{}.json", name))
195+
let sanitized_name = sanitize_server_name(name);
196+
self.mcps_dir.join(format!("{}.json", sanitized_name))
163197
}
164198

165199
/// Save an MCP server configuration
@@ -372,4 +406,44 @@ mod tests {
372406
let result = storage.load_server("nonexistent").unwrap();
373407
assert!(result.is_none());
374408
}
409+
410+
#[test]
411+
fn test_sanitize_server_name() {
412+
// Normal names stay the same
413+
assert_eq!(sanitize_server_name("my-server"), "my-server");
414+
assert_eq!(sanitize_server_name("server_123"), "server_123");
415+
416+
// Path traversal attempts get sanitized
417+
assert_eq!(sanitize_server_name("../../../etc"), "________etc");
418+
assert_eq!(sanitize_server_name("test/subdir"), "test_subdir");
419+
assert_eq!(sanitize_server_name("test\\windows"), "test_windows");
420+
}
421+
422+
#[test]
423+
fn test_validate_server_name() {
424+
// Valid names
425+
assert!(validate_server_name("my-server"));
426+
assert!(validate_server_name("server_123"));
427+
assert!(validate_server_name("ABC"));
428+
429+
// Invalid names
430+
assert!(!validate_server_name("../../../etc"));
431+
assert!(!validate_server_name("test/subdir"));
432+
assert!(!validate_server_name(""));
433+
assert!(!validate_server_name("name with spaces"));
434+
}
435+
436+
#[test]
437+
fn test_server_path_traversal() {
438+
let (storage, tmp) = test_storage();
439+
let base_dir = tmp.path().to_path_buf();
440+
441+
// Attempt path traversal
442+
let malicious_name = "../../../etc/passwd";
443+
let result_path = storage.server_path(malicious_name);
444+
445+
// The result should still be under mcps_dir
446+
assert!(result_path.starts_with(base_dir.join("mcps")));
447+
assert!(!result_path.to_string_lossy().contains(".."));
448+
}
375449
}

src/cortex-tui/src/session/storage.rs

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,37 @@ const META_FILE: &str = "meta.json";
2121
/// History file name.
2222
const HISTORY_FILE: &str = "history.jsonl";
2323

24+
// ============================================================
25+
// SECURITY HELPERS
26+
// ============================================================
27+
28+
/// Sanitize a session ID to prevent path traversal attacks.
29+
///
30+
/// Only allows alphanumeric characters, hyphens, and underscores.
31+
/// Any other characters (including path separators) are replaced with underscores.
32+
fn sanitize_session_id(session_id: &str) -> String {
33+
session_id
34+
.chars()
35+
.map(|c| {
36+
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
37+
c
38+
} else {
39+
'_'
40+
}
41+
})
42+
.collect()
43+
}
44+
45+
/// Validate a session ID for safe filesystem use.
46+
///
47+
/// Returns true if the session_id contains only safe characters.
48+
pub fn validate_session_id(session_id: &str) -> bool {
49+
!session_id.is_empty()
50+
&& session_id
51+
.chars()
52+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
53+
}
54+
2455
// ============================================================
2556
// SESSION STORAGE
2657
// ============================================================
@@ -49,8 +80,21 @@ impl SessionStorage {
4980
}
5081

5182
/// Gets the directory for a specific session.
83+
///
84+
/// # Security
85+
///
86+
/// The session_id is validated to prevent path traversal attacks.
87+
/// Only alphanumeric characters, hyphens, and underscores are allowed.
88+
///
89+
/// # Panics
90+
///
91+
/// This function does not panic but will return an invalid path if
92+
/// the session_id contains disallowed characters. Use `validate_session_id`
93+
/// before calling this function for untrusted input.
5294
pub fn session_dir(&self, session_id: &str) -> PathBuf {
53-
self.base_dir.join(session_id)
95+
// Sanitize session_id to prevent path traversal
96+
let sanitized_id = sanitize_session_id(session_id);
97+
self.base_dir.join(&sanitized_id)
5498
}
5599

56100
/// Gets the metadata file path for a session.
@@ -429,4 +473,45 @@ mod tests {
429473
let loaded = storage.load_meta(&session_id).unwrap();
430474
assert!(loaded.archived);
431475
}
476+
477+
#[test]
478+
fn test_validate_session_id() {
479+
// Valid IDs
480+
assert!(validate_session_id("abc-123"));
481+
assert!(validate_session_id("test_session"));
482+
assert!(validate_session_id("ABC123"));
483+
484+
// Invalid IDs - path traversal attempts
485+
assert!(!validate_session_id("../../../etc"));
486+
assert!(!validate_session_id(".."));
487+
assert!(!validate_session_id("test/../passwd"));
488+
assert!(!validate_session_id("test/subdir"));
489+
assert!(!validate_session_id(""));
490+
}
491+
492+
#[test]
493+
fn test_sanitize_session_id() {
494+
// Normal ID stays the same
495+
assert_eq!(sanitize_session_id("abc-123"), "abc-123");
496+
assert_eq!(sanitize_session_id("test_session"), "test_session");
497+
498+
// Path traversal gets sanitized
499+
assert_eq!(sanitize_session_id("../../../etc"), "________etc");
500+
assert_eq!(sanitize_session_id("test/subdir"), "test_subdir");
501+
assert_eq!(sanitize_session_id("test\x00evil"), "test_evil");
502+
}
503+
504+
#[test]
505+
fn test_session_dir_path_traversal() {
506+
let (storage, temp) = create_test_storage();
507+
let base_dir = temp.path().to_path_buf();
508+
509+
// Attempt path traversal - should be sanitized
510+
let malicious_id = "../../../etc/passwd";
511+
let result_path = storage.session_dir(malicious_id);
512+
513+
// The result should still be under base_dir, not escaping it
514+
assert!(result_path.starts_with(&base_dir));
515+
assert!(!result_path.to_string_lossy().contains(".."));
516+
}
432517
}

0 commit comments

Comments
 (0)