use crate::ssh::ssh_config::parser::helpers::parse_yes_no;
use crate::ssh::ssh_config::security::validate_executable_string;
use crate::ssh::ssh_config::types::SshHostConfig;
use anyhow::{Context, Result};
pub(super) fn parse_command_option(
host: &mut SshHostConfig,
keyword: &str,
args: &[String],
line_number: usize,
) -> Result<()> {
match keyword {
"permitlocalcommand" => {
if args.is_empty() {
anyhow::bail!("PermitLocalCommand requires a value at line {line_number}");
}
host.permit_local_command = Some(parse_yes_no(&args[0], line_number)?);
}
"localcommand" => {
if args.is_empty() {
anyhow::bail!("LocalCommand requires a value at line {line_number}");
}
let command = args.join(" ");
validate_command_with_tokens(&command, "LocalCommand", line_number)?;
host.local_command = Some(command);
}
"remotecommand" => {
if args.is_empty() {
anyhow::bail!("RemoteCommand requires a value at line {line_number}");
}
let command = args.join(" ");
validate_remote_command_warnings(&command, line_number);
host.remote_command = Some(command);
}
"knownhostscommand" => {
if args.is_empty() {
anyhow::bail!("KnownHostsCommand requires a value at line {line_number}");
}
let command = args.join(" ");
validate_command_with_tokens(&command, "KnownHostsCommand", line_number)?;
host.known_hosts_command = Some(command);
}
"forkafterauthentication" => {
if args.is_empty() {
anyhow::bail!("ForkAfterAuthentication requires a value at line {line_number}");
}
host.fork_after_authentication = Some(parse_yes_no(&args[0], line_number)?);
}
"sessiontype" => {
if args.is_empty() {
anyhow::bail!("SessionType requires a value at line {line_number}");
}
let value = args[0].to_lowercase();
match value.as_str() {
"none" | "subsystem" | "default" => {
host.session_type = Some(value);
}
_ => {
anyhow::bail!(
"Invalid SessionType value '{value}' at line {line_number} (expected: none, subsystem, or default)"
);
}
}
}
"stdinnull" => {
if args.is_empty() {
anyhow::bail!("StdinNull requires a value at line {line_number}");
}
host.stdin_null = Some(parse_yes_no(&args[0], line_number)?);
}
_ => unreachable!("Unexpected keyword in parse_command_option: {}", keyword),
}
Ok(())
}
fn validate_remote_command_warnings(command: &str, line_number: usize) {
let lower_command = command.to_lowercase();
if lower_command.contains("ssh ")
|| lower_command.contains("scp ")
|| lower_command.contains("rsync ")
{
tracing::warn!(
"RemoteCommand at line {} contains SSH/SCP/rsync command '{}'. \
This could be used for lateral movement attacks. \
Ensure this is intentional and the remote host is trusted.",
line_number,
command.split_whitespace().next().unwrap_or("")
);
}
if lower_command.contains("curl ")
|| lower_command.contains("wget ")
|| lower_command.contains("nc ")
|| lower_command.contains("netcat ")
{
tracing::warn!(
"RemoteCommand at line {} contains network command '{}'. \
This could download malware or exfiltrate data from the remote host. \
Ensure this is intentional.",
line_number,
command.split_whitespace().next().unwrap_or("")
);
}
if lower_command.contains("sudo ")
|| lower_command.contains("su ")
|| lower_command.contains("doas ")
|| lower_command.contains("pkexec ")
{
tracing::warn!(
"RemoteCommand at line {} contains privilege escalation command '{}'. \
Verify that elevated privileges are necessary and properly authorized.",
line_number,
command.split_whitespace().next().unwrap_or("")
);
}
if lower_command.contains("chmod ")
|| lower_command.contains("chown ")
|| lower_command.contains("usermod ")
|| lower_command.contains("adduser ")
|| lower_command.contains("useradd ")
{
tracing::warn!(
"RemoteCommand at line {} modifies system configuration with '{}'. \
Ensure these changes are authorized and necessary.",
line_number,
command.split_whitespace().next().unwrap_or("")
);
}
}
fn validate_command_with_tokens(
command: &str,
option_name: &str,
line_number: usize,
) -> Result<()> {
if command.trim().is_empty() {
anyhow::bail!("{option_name} cannot be empty at line {line_number}");
}
let token_count = command.matches("%h").count()
+ command.matches("%H").count()
+ command.matches("%n").count()
+ command.matches("%p").count()
+ command.matches("%r").count()
+ command.matches("%u").count();
const MAX_TOKENS: usize = 50; if token_count > MAX_TOKENS {
anyhow::bail!(
"Security violation: {option_name} contains excessive token usage ({token_count} tokens) at line {line_number}. \
Maximum allowed is {MAX_TOKENS} tokens to prevent resource exhaustion."
);
}
const MAX_EXPANDED_LENGTH: usize = 8192; let potential_length = command.len() + (token_count * 255);
if potential_length > MAX_EXPANDED_LENGTH {
anyhow::bail!(
"Security violation: {option_name} could expand to {potential_length} bytes at line {line_number}. \
Maximum allowed expanded length is {MAX_EXPANDED_LENGTH} bytes."
);
}
let chars: Vec<char> = command.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' {
if i + 1 < chars.len() {
let next_char = chars[i + 1];
match next_char {
'h' | 'H' | 'n' | 'p' | 'r' | 'u' | '%' => {
i += 2;
}
_ => {
anyhow::bail!(
"Invalid token '%{next_char}' in {option_name} at line {line_number}. \
Valid tokens are: %h, %H, %n, %p, %r, %u, %%"
);
}
}
} else {
anyhow::bail!(
"Incomplete token '%' at end of {option_name} at line {line_number}. \
Valid tokens are: %h, %H, %n, %p, %r, %u, %%"
);
}
} else {
i += 1;
}
}
let mut sanitized = command.to_string();
sanitized = sanitized.replace("%%", "__DOUBLE_PERCENT__");
let tokens = [
("%h", "HOSTNAME"),
("%H", "HOSTNAME"),
("%n", "ORIGINAL"),
("%p", "22"),
("%r", "USER"),
("%u", "LOCALUSER"),
];
for (token, replacement) in tokens.iter() {
sanitized = sanitized.replace(token, replacement);
}
sanitized = sanitized.replace("__DOUBLE_PERCENT__", "%");
validate_executable_string(&sanitized, option_name, line_number).with_context(|| {
format!("Security validation failed for {option_name} at line {line_number}")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_command_with_tokens_valid() {
assert!(
validate_command_with_tokens("rsync -av ~/project/ %h:~/project/", "LocalCommand", 1)
.is_ok()
);
assert!(
validate_command_with_tokens(
"notify-send \"Connected to %h on port %p\"",
"LocalCommand",
1
)
.is_ok()
);
assert!(
validate_command_with_tokens(
"/usr/local/bin/fetch-host-key %H",
"KnownHostsCommand",
1
)
.is_ok()
);
assert!(
validate_command_with_tokens("echo \"Progress: 50%% complete\"", "LocalCommand", 1)
.is_ok()
);
}
#[test]
fn test_validate_command_with_tokens_invalid() {
assert!(validate_command_with_tokens("echo %x", "LocalCommand", 1).is_err());
assert!(validate_command_with_tokens("echo test; rm -rf /", "LocalCommand", 1).is_err());
assert!(validate_command_with_tokens("echo $(whoami)", "LocalCommand", 1).is_err());
assert!(validate_command_with_tokens("", "LocalCommand", 1).is_err());
assert!(validate_command_with_tokens("ls | grep test", "LocalCommand", 1).is_err());
}
#[test]
fn test_validate_command_token_rate_limiting() {
let many_tokens = "%h".repeat(100); assert!(validate_command_with_tokens(&many_tokens, "LocalCommand", 1).is_err());
let ok_tokens = format!("echo {}", "%h ".repeat(20)); assert!(validate_command_with_tokens(&ok_tokens, "LocalCommand", 1).is_ok());
let huge_expansion = format!("{} {}", "%h".repeat(30), "x".repeat(1000));
assert!(validate_command_with_tokens(&huge_expansion, "LocalCommand", 1).is_err());
let at_limit = "%p ".repeat(50);
assert!(validate_command_with_tokens(&at_limit, "LocalCommand", 1).is_err());
let over_limit = "%p ".repeat(51);
assert!(validate_command_with_tokens(&over_limit, "LocalCommand", 1).is_err());
}
#[test]
fn test_parse_permit_local_command() {
let mut config = SshHostConfig::default();
assert!(
parse_command_option(&mut config, "permitlocalcommand", &["yes".to_string()], 1)
.is_ok()
);
assert_eq!(config.permit_local_command, Some(true));
assert!(
parse_command_option(&mut config, "permitlocalcommand", &["no".to_string()], 1).is_ok()
);
assert_eq!(config.permit_local_command, Some(false));
assert!(
parse_command_option(&mut config, "permitlocalcommand", &["maybe".to_string()], 1)
.is_err()
);
assert!(parse_command_option(&mut config, "permitlocalcommand", &[], 1).is_err());
}
#[test]
fn test_parse_session_type() {
let mut config = SshHostConfig::default();
for value in ["none", "subsystem", "default"] {
assert!(
parse_command_option(&mut config, "sessiontype", &[value.to_string()], 1).is_ok()
);
assert_eq!(config.session_type, Some(value.to_string()));
}
assert!(parse_command_option(&mut config, "sessiontype", &["NONE".to_string()], 1).is_ok());
assert_eq!(config.session_type, Some("none".to_string()));
assert!(
parse_command_option(&mut config, "sessiontype", &["invalid".to_string()], 1).is_err()
);
assert!(parse_command_option(&mut config, "sessiontype", &[], 1).is_err());
}
#[test]
fn test_parse_remote_command() {
let mut config = SshHostConfig::default();
assert!(
parse_command_option(
&mut config,
"remotecommand",
&["ls".to_string(), "-la".to_string()],
1
)
.is_ok()
);
assert_eq!(config.remote_command, Some("ls -la".to_string()));
assert!(
parse_command_option(
&mut config,
"remotecommand",
&[
"tmux".to_string(),
"attach".to_string(),
"-t".to_string(),
"dev".to_string(),
"||".to_string(),
"tmux".to_string(),
"new".to_string()
],
1
)
.is_ok()
);
assert_eq!(
config.remote_command,
Some("tmux attach -t dev || tmux new".to_string())
);
assert!(parse_command_option(&mut config, "remotecommand", &[], 1).is_err());
}
}