use susshi::config::{ConnectionMode, ResolvedServer};
use susshi::ssh::client::build_ssh_args;
fn base_server() -> ResolvedServer {
ResolvedServer {
namespace: String::new(),
group_name: "sec".into(),
env_name: "test".into(),
name: "srv".into(),
host: "10.0.0.1".into(),
user: "ops".into(),
port: 22,
ssh_key: String::new(),
ssh_options: vec![],
default_mode: ConnectionMode::Direct,
jump_host: None,
bastion_host: None,
bastion_user: None,
bastion_template: "{target_user}@%n:SSH:{bastion_user}".into(),
use_system_ssh_config: false,
probe_filesystems: vec![],
tunnels: vec![],
tags: vec![],
control_master: false,
agent_forwarding: false,
control_path: String::new(),
control_persist: "10m".to_string(),
pre_connect_hook: None,
post_disconnect_hook: None,
hook_timeout_secs: 5,
ssh_cert: String::new(),
notes: String::new(),
ssh_agent_sock: String::new(),
wallix_group: None,
wallix_account: "default".to_string(),
wallix_protocol: "SSH".to_string(),
wallix_auto_select: true,
wallix_fail_if_menu_match_error: true,
wallix_selection_timeout_secs: 8,
wallix_direct: false,
wallix_authorization: None,
wallix_header_columns: vec![],
}
}
#[test]
fn hostname_with_semicolon_is_single_arg() {
let mut s = base_server();
s.host = "10.0.0.1; rm -rf /".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
let last = args.last().unwrap();
assert_eq!(last, "ops@10.0.0.1; rm -rf /");
let semicolons_in_args = args.iter().filter(|a| a.contains("; rm")).count();
assert_eq!(
semicolons_in_args, 1,
"semicolon should appear in exactly one arg"
);
}
#[test]
fn hostname_with_backticks_is_literal() {
let mut s = base_server();
s.host = "10.0.0.1`whoami`".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
assert_eq!(args.last().unwrap(), "ops@10.0.0.1`whoami`");
}
#[test]
fn hostname_with_dollar_subshell_is_literal() {
let mut s = base_server();
s.host = "10.0.0.1$(id)".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
assert_eq!(args.last().unwrap(), "ops@10.0.0.1$(id)");
}
#[test]
fn username_with_semicolon_is_single_arg() {
let mut s = base_server();
s.user = "admin; evil".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
assert_eq!(args.last().unwrap(), "admin; evil@10.0.0.1");
}
#[test]
fn username_with_newline_is_single_arg() {
let mut s = base_server();
s.user = "admin\nmalicious".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
assert_eq!(args.last().unwrap(), "admin\nmalicious@10.0.0.1");
}
#[test]
fn key_path_tilde_is_expanded() {
let mut s = base_server();
s.ssh_key = "~/.ssh/id_ed25519".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
let i_pos = args.iter().position(|a| a == "-i").expect("-i expected");
assert!(
!args[i_pos + 1].starts_with('~'),
"tilde must be expanded, got: {}",
args[i_pos + 1]
);
assert!(args[i_pos + 1].ends_with("/.ssh/id_ed25519"));
}
#[test]
fn key_path_dotdot_passed_through_to_ssh() {
let mut s = base_server();
s.ssh_key = "../../etc/passwd".into();
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
let i_pos = args.iter().position(|a| a == "-i").expect("-i expected");
assert_eq!(args[i_pos + 1], "../../etc/passwd");
}
#[test]
fn ssh_option_with_semicolon_is_two_args() {
let mut s = base_server();
s.ssh_options = vec!["ServerAliveInterval=30; rm -rf /".into()];
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
let o_pos = args.iter().position(|a| a == "-o").expect("-o expected");
assert_eq!(args[o_pos + 1], "ServerAliveInterval=30; rm -rf /");
assert!(
!args.iter().any(|a| a.trim_start().starts_with("rm")),
"rm must not appear as a separate arg"
);
}
#[test]
fn user_strict_host_checking_prevents_auto_inject() {
let mut s = base_server();
s.ssh_options = vec!["StrictHostKeyChecking=no".into()];
let args = build_ssh_args(&s, ConnectionMode::Direct, false).unwrap();
let accept_new_count = args
.iter()
.filter(|a| a.contains("StrictHostKeyChecking=accept-new"))
.count();
assert_eq!(
accept_new_count, 0,
"accept-new must not be injected when user already sets StrictHostKeyChecking"
);
}
#[test]
fn wallix_group_with_semicolon_is_literal_in_login_string() {
let mut s = base_server();
s.bastion_host = Some("bastion.example.com".into());
s.bastion_user = Some("bops".into());
s.wallix_group = Some("group; malicious".into());
let args = build_ssh_args(&s, ConnectionMode::Wallix, false).unwrap();
let l_pos = args.iter().position(|a| a == "-l").expect("-l expected");
let login = &args[l_pos + 1];
assert!(login.contains("group; malicious"), "login string: {login}");
assert!(
!args.iter().any(|a| a.trim_start().starts_with("malicious")),
"malicious must not appear as a standalone arg"
);
}
#[test]
fn wallix_group_with_newline_is_single_arg() {
let mut s = base_server();
s.bastion_host = Some("bastion.example.com".into());
s.bastion_user = Some("bops".into());
s.wallix_group = Some("group\nevil-option".into());
let args = build_ssh_args(&s, ConnectionMode::Wallix, false).unwrap();
let l_pos = args.iter().position(|a| a == "-l").expect("-l expected");
let login = &args[l_pos + 1];
assert!(
login.contains('\n'),
"newline must be inside the -l value, got: {login:?}"
);
}