Skip to main content

shell_tunnel/security/
validation.rs

1//! Input validation and command sanitization.
2
3use std::time::Duration;
4
5/// Validation configuration.
6#[derive(Debug, Clone)]
7pub struct ValidationConfig {
8    /// Maximum command length in characters.
9    pub max_command_length: usize,
10    /// Maximum output size in bytes.
11    pub max_output_size: usize,
12    /// Maximum timeout in seconds.
13    pub max_timeout_secs: u64,
14    /// Minimum timeout in seconds.
15    pub min_timeout_secs: u64,
16    /// Whether to block dangerous commands.
17    pub block_dangerous: bool,
18    /// Custom blocked patterns (regex-like simple patterns).
19    pub blocked_patterns: Vec<String>,
20}
21
22impl Default for ValidationConfig {
23    fn default() -> Self {
24        Self {
25            max_command_length: 4096,
26            max_output_size: 10 * 1024 * 1024, // 10MB
27            max_timeout_secs: 300,             // 5 minutes
28            min_timeout_secs: 1,
29            block_dangerous: true,
30            blocked_patterns: Vec::new(),
31        }
32    }
33}
34
35impl ValidationConfig {
36    /// Create a permissive config (for trusted environments).
37    pub fn permissive() -> Self {
38        Self {
39            max_command_length: 65536,
40            max_output_size: 100 * 1024 * 1024, // 100MB
41            max_timeout_secs: 3600,             // 1 hour
42            min_timeout_secs: 1,
43            block_dangerous: false,
44            blocked_patterns: Vec::new(),
45        }
46    }
47
48    /// Create a strict config (for untrusted environments).
49    pub fn strict() -> Self {
50        Self {
51            max_command_length: 1024,
52            max_output_size: 1024 * 1024, // 1MB
53            max_timeout_secs: 60,
54            min_timeout_secs: 1,
55            block_dangerous: true,
56            blocked_patterns: vec![
57                "rm -rf".to_string(),
58                "mkfs".to_string(),
59                "dd if=".to_string(),
60                ":(){".to_string(), // Fork bomb
61                ">(w)".to_string(),
62            ],
63        }
64    }
65}
66
67/// Command validator.
68#[derive(Debug)]
69pub struct CommandValidator {
70    config: ValidationConfig,
71}
72
73impl CommandValidator {
74    /// Create a new validator with the given config.
75    pub fn new(config: ValidationConfig) -> Self {
76        Self { config }
77    }
78
79    /// Validate a command string.
80    pub fn validate_command(&self, command: &str) -> Result<(), ValidationError> {
81        // Check length
82        if command.len() > self.config.max_command_length {
83            return Err(ValidationError::CommandTooLong {
84                length: command.len(),
85                max: self.config.max_command_length,
86            });
87        }
88
89        // Check for empty command
90        if command.trim().is_empty() {
91            return Err(ValidationError::EmptyCommand);
92        }
93
94        // Check for dangerous patterns
95        if self.config.block_dangerous {
96            if let Some(pattern) = self.check_dangerous_patterns(command) {
97                return Err(ValidationError::DangerousCommand {
98                    pattern: pattern.to_string(),
99                });
100            }
101        }
102
103        // Check custom blocked patterns
104        for pattern in &self.config.blocked_patterns {
105            if command.contains(pattern) {
106                return Err(ValidationError::BlockedPattern {
107                    pattern: pattern.clone(),
108                });
109            }
110        }
111
112        // Check for null bytes
113        if command.contains('\0') {
114            return Err(ValidationError::InvalidCharacter('\0'));
115        }
116
117        Ok(())
118    }
119
120    /// Validate a timeout value.
121    pub fn validate_timeout(&self, timeout_secs: u64) -> Result<Duration, ValidationError> {
122        if timeout_secs < self.config.min_timeout_secs {
123            return Err(ValidationError::TimeoutTooShort {
124                value: timeout_secs,
125                min: self.config.min_timeout_secs,
126            });
127        }
128
129        if timeout_secs > self.config.max_timeout_secs {
130            return Err(ValidationError::TimeoutTooLong {
131                value: timeout_secs,
132                max: self.config.max_timeout_secs,
133            });
134        }
135
136        Ok(Duration::from_secs(timeout_secs))
137    }
138
139    /// Validate working directory path.
140    pub fn validate_working_dir(&self, path: &str) -> Result<(), ValidationError> {
141        // Check for path traversal attempts
142        if path.contains("..") {
143            return Err(ValidationError::PathTraversal);
144        }
145
146        // Check for null bytes
147        if path.contains('\0') {
148            return Err(ValidationError::InvalidCharacter('\0'));
149        }
150
151        // Basic length check
152        if path.len() > 4096 {
153            return Err(ValidationError::PathTooLong {
154                length: path.len(),
155                max: 4096,
156            });
157        }
158
159        Ok(())
160    }
161
162    /// Check for common dangerous command patterns.
163    fn check_dangerous_patterns(&self, command: &str) -> Option<&'static str> {
164        let lower = command.to_lowercase();
165
166        // Dangerous file operations
167        if lower.contains("rm -rf /") || lower.contains("rm -fr /") {
168            return Some("rm -rf /");
169        }
170
171        // Format/partition commands
172        if lower.contains("mkfs") || lower.contains("fdisk") || lower.contains("parted") {
173            return Some("disk formatting");
174        }
175
176        // Raw disk access
177        if lower.contains("dd if=/dev") && lower.contains("of=/dev") {
178            return Some("raw disk write");
179        }
180
181        // Fork bomb patterns
182        if lower.contains(":(){") || lower.contains(":(){ :|:& };:") {
183            return Some("fork bomb");
184        }
185
186        // Shutdown/reboot
187        if lower.contains("shutdown") || lower.contains("reboot") || lower.contains("init 0") {
188            return Some("system shutdown");
189        }
190
191        // Dangerous redirections
192        if lower.contains("> /dev/sd") || lower.contains("> /dev/nvme") {
193            return Some("device overwrite");
194        }
195
196        None
197    }
198
199    /// Get the max output size.
200    pub fn max_output_size(&self) -> usize {
201        self.config.max_output_size
202    }
203}
204
205impl Default for CommandValidator {
206    fn default() -> Self {
207        Self::new(ValidationConfig::default())
208    }
209}
210
211/// Validation errors.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum ValidationError {
214    /// Command exceeds maximum length.
215    CommandTooLong { length: usize, max: usize },
216    /// Command is empty.
217    EmptyCommand,
218    /// Command contains dangerous pattern.
219    DangerousCommand { pattern: String },
220    /// Command matches blocked pattern.
221    BlockedPattern { pattern: String },
222    /// Command contains invalid character.
223    InvalidCharacter(char),
224    /// Timeout is too short.
225    TimeoutTooShort { value: u64, min: u64 },
226    /// Timeout is too long.
227    TimeoutTooLong { value: u64, max: u64 },
228    /// Path contains traversal attempt.
229    PathTraversal,
230    /// Path is too long.
231    PathTooLong { length: usize, max: usize },
232}
233
234impl std::fmt::Display for ValidationError {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            Self::CommandTooLong { length, max } => {
238                write!(f, "Command too long: {} chars (max: {})", length, max)
239            }
240            Self::EmptyCommand => write!(f, "Command cannot be empty"),
241            Self::DangerousCommand { pattern } => {
242                write!(f, "Dangerous command pattern detected: {}", pattern)
243            }
244            Self::BlockedPattern { pattern } => {
245                write!(f, "Command contains blocked pattern: {}", pattern)
246            }
247            Self::InvalidCharacter(c) => {
248                write!(f, "Command contains invalid character: {:?}", c)
249            }
250            Self::TimeoutTooShort { value, min } => {
251                write!(f, "Timeout too short: {}s (min: {}s)", value, min)
252            }
253            Self::TimeoutTooLong { value, max } => {
254                write!(f, "Timeout too long: {}s (max: {}s)", value, max)
255            }
256            Self::PathTraversal => write!(f, "Path traversal detected"),
257            Self::PathTooLong { length, max } => {
258                write!(f, "Path too long: {} chars (max: {})", length, max)
259            }
260        }
261    }
262}
263
264impl std::error::Error for ValidationError {}
265
266/// Sanitize a command string.
267///
268/// This removes potentially dangerous characters without blocking the command.
269/// Use this for logging or display purposes.
270pub fn sanitize_for_display(command: &str) -> String {
271    command
272        .chars()
273        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
274        .take(1000) // Limit display length
275        .collect()
276}
277
278/// Check if a string looks like a shell injection attempt.
279pub fn looks_like_injection(input: &str) -> bool {
280    let suspicious_patterns = [
281        "$(", "`", "${", "&&", "||", ";", "|", ">", "<", "\\n", "\\r",
282    ];
283
284    suspicious_patterns.iter().any(|p| input.contains(p))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_validation_config_default() {
293        let config = ValidationConfig::default();
294        assert_eq!(config.max_command_length, 4096);
295        assert!(config.block_dangerous);
296    }
297
298    #[test]
299    fn test_validation_config_strict() {
300        let config = ValidationConfig::strict();
301        assert_eq!(config.max_command_length, 1024);
302        assert!(config.block_dangerous);
303        assert!(!config.blocked_patterns.is_empty());
304    }
305
306    #[test]
307    fn test_validate_command_ok() {
308        let validator = CommandValidator::default();
309
310        assert!(validator.validate_command("ls -la").is_ok());
311        assert!(validator.validate_command("echo hello world").is_ok());
312        assert!(validator.validate_command("cat /etc/passwd").is_ok());
313    }
314
315    #[test]
316    fn test_validate_command_empty() {
317        let validator = CommandValidator::default();
318
319        assert!(matches!(
320            validator.validate_command(""),
321            Err(ValidationError::EmptyCommand)
322        ));
323        assert!(matches!(
324            validator.validate_command("   "),
325            Err(ValidationError::EmptyCommand)
326        ));
327    }
328
329    #[test]
330    fn test_validate_command_too_long() {
331        let validator = CommandValidator::new(ValidationConfig {
332            max_command_length: 10,
333            ..Default::default()
334        });
335
336        let result = validator.validate_command("this is a very long command");
337        assert!(matches!(
338            result,
339            Err(ValidationError::CommandTooLong { .. })
340        ));
341    }
342
343    #[test]
344    fn test_validate_dangerous_rm() {
345        let validator = CommandValidator::default();
346
347        assert!(matches!(
348            validator.validate_command("rm -rf /"),
349            Err(ValidationError::DangerousCommand { .. })
350        ));
351        assert!(matches!(
352            validator.validate_command("sudo rm -rf /home"),
353            Err(ValidationError::DangerousCommand { .. })
354        ));
355    }
356
357    #[test]
358    fn test_validate_dangerous_fork_bomb() {
359        let validator = CommandValidator::default();
360
361        assert!(matches!(
362            validator.validate_command(":(){ :|:& };:"),
363            Err(ValidationError::DangerousCommand { .. })
364        ));
365    }
366
367    #[test]
368    fn test_validate_dangerous_shutdown() {
369        let validator = CommandValidator::default();
370
371        assert!(matches!(
372            validator.validate_command("shutdown -h now"),
373            Err(ValidationError::DangerousCommand { .. })
374        ));
375        assert!(matches!(
376            validator.validate_command("reboot"),
377            Err(ValidationError::DangerousCommand { .. })
378        ));
379    }
380
381    #[test]
382    fn test_validate_null_byte() {
383        let validator = CommandValidator::default();
384
385        assert!(matches!(
386            validator.validate_command("ls\0 -la"),
387            Err(ValidationError::InvalidCharacter('\0'))
388        ));
389    }
390
391    #[test]
392    fn test_validate_timeout() {
393        let validator = CommandValidator::default();
394
395        assert!(validator.validate_timeout(30).is_ok());
396        assert!(validator.validate_timeout(1).is_ok());
397        assert!(validator.validate_timeout(300).is_ok());
398
399        assert!(matches!(
400            validator.validate_timeout(0),
401            Err(ValidationError::TimeoutTooShort { .. })
402        ));
403        assert!(matches!(
404            validator.validate_timeout(1000),
405            Err(ValidationError::TimeoutTooLong { .. })
406        ));
407    }
408
409    #[test]
410    fn test_validate_working_dir() {
411        let validator = CommandValidator::default();
412
413        assert!(validator.validate_working_dir("/home/user").is_ok());
414        assert!(validator.validate_working_dir("/tmp").is_ok());
415
416        assert!(matches!(
417            validator.validate_working_dir("/home/../etc"),
418            Err(ValidationError::PathTraversal)
419        ));
420    }
421
422    #[test]
423    fn test_blocked_patterns() {
424        let validator = CommandValidator::new(ValidationConfig::strict());
425
426        // "mkfs" is in strict blocked patterns but also dangerous
427        // Use a pattern that's only in blocked_patterns
428        assert!(matches!(
429            validator.validate_command("dd if=/dev/zero"),
430            Err(ValidationError::BlockedPattern { .. })
431        ));
432    }
433
434    #[test]
435    fn test_permissive_allows_dangerous() {
436        let validator = CommandValidator::new(ValidationConfig::permissive());
437
438        // Permissive mode doesn't block dangerous commands
439        assert!(validator.validate_command("rm -rf /").is_ok());
440    }
441
442    #[test]
443    fn test_sanitize_for_display() {
444        assert_eq!(sanitize_for_display("hello"), "hello");
445        assert_eq!(sanitize_for_display("hello\nworld"), "hello\nworld");
446        assert_eq!(sanitize_for_display("hello\x00world"), "helloworld");
447
448        // Long string is truncated
449        let long = "a".repeat(2000);
450        assert_eq!(sanitize_for_display(&long).len(), 1000);
451    }
452
453    #[test]
454    fn test_looks_like_injection() {
455        assert!(looks_like_injection("echo $(whoami)"));
456        assert!(looks_like_injection("echo `id`"));
457        assert!(looks_like_injection("cmd1 && cmd2"));
458        assert!(looks_like_injection("cmd1 | cmd2"));
459
460        assert!(!looks_like_injection("echo hello"));
461        assert!(!looks_like_injection("ls -la"));
462    }
463}