Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ use miette::{IntoDiagnostic, Report, Result, WrapErr};
use nix::sys::signal::Signal;
use openshell_core::driver_mounts;
use openshell_core::forward::{
ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, sandbox_ssh_args,
shell_escape, validate_ssh_session_response, write_forward_pid,
ForwardSpec, build_proxy_command, format_gateway_url, proxy_command_escape,
resolve_ssh_gateway, sandbox_ssh_args, shell_escape, validate_ssh_session_response,
write_forward_pid,
};
use openshell_core::proto::{
CreateSshSessionRequest, GetSandboxRequest, SandboxPhase, SshRelayTarget, TcpForwardFrame,
Expand Down Expand Up @@ -1334,8 +1335,11 @@ fn ssh_upload_command(proxy_command: &str) -> Command {
/// `"/sandbox/sub/file"` -> `("/sandbox/sub", "file")`
/// `"file.txt"` -> `(".", "file.txt")`
fn split_sandbox_path(path: &str) -> (&str, &str) {
match path.rfind('/') {
Some(0) => ("/", &path[1..]),
match path.rfind(['/', '\\']) {
Some(0) => (&path[..1], &path[1..]),
Some(pos) if pos == 2 && path.as_bytes().get(1) == Some(&b':') => {
(&path[..=pos], &path[pos + 1..])
}
Some(pos) => (&path[..pos], &path[pos + 1..]),
None => (".", path),
}
Expand Down Expand Up @@ -2126,13 +2130,13 @@ fn host_alias(name: &str, workspace: &str) -> String {

fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String {
let exe = std::env::current_exe().expect("failed to resolve OpenShell executable");
let exe = shell_escape(&exe.to_string_lossy());
let exe = proxy_command_escape(&exe.to_string_lossy());

let proxy_cmd = format!(
"{exe} ssh-proxy --gateway-name {} --name {} --workspace {}",
shell_escape(gateway),
shell_escape(name),
shell_escape(workspace),
proxy_command_escape(gateway),
proxy_command_escape(name),
proxy_command_escape(workspace),
);
let host_alias = host_alias(name, workspace);
format!(
Expand Down
98 changes: 88 additions & 10 deletions crates/openshell-core/src/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String {
format!("{scheme}://{}:{port}", bracket_ipv6_host(host))
}

/// Shell-escape a value for use inside a `ProxyCommand` string.
/// POSIX shell-escape a value for use in command strings.
pub fn shell_escape(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
Expand All @@ -923,11 +923,55 @@ pub fn shell_escape(value: &str) -> String {
format!("'{escaped}'")
}

/// Quote one argument for the Windows command-line parser used by
/// `CreateProcessW`-launched programs.
///
/// OpenSSH executes `ProxyCommand` directly on Windows instead of passing it
/// through `/bin/sh`. Always quoting each argument keeps executable paths with
/// spaces intact. Backslashes immediately before a quote or the closing quote
/// are doubled according to the Windows C runtime argument parsing rules.
#[cfg(any(windows, test))]
fn windows_command_line_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
let mut backslashes = 0;
for character in value.chars() {
if character == '\\' {
backslashes += 1;
continue;
}
let count = if character == '"' {
backslashes * 2 + 1
} else {
backslashes
};
escaped.extend(std::iter::repeat_n('\\', count));
backslashes = 0;
escaped.push(character);
}
escaped.extend(std::iter::repeat_n('\\', backslashes * 2));
escaped.push('"');
escaped
}

/// Escape one argument for the command-launch behavior OpenSSH uses for
/// `ProxyCommand` on the current host platform.
pub fn proxy_command_escape(value: &str) -> String {
#[cfg(not(windows))]
{
shell_escape(value)
}
#[cfg(windows)]
{
windows_command_line_escape(value)
}
}

/// Build the SSH `ProxyCommand` string used to tunnel to a sandbox.
///
/// Every interpolated argument is shell-escaped so that server-supplied values
/// (gateway URL, sandbox name, workspace, token, gateway name) cannot inject shell
/// metacharacters into the command that OpenSSH executes via `/bin/sh -c`.
/// On Unix, every interpolated argument is shell-escaped for the `/bin/sh -c`
/// invocation used by OpenSSH. On Windows, every argument is quoted for the
/// command line that OpenSSH launches through `CreateProcessW`.
pub fn build_proxy_command(
exe: &str,
gateway_url: &str,
Expand All @@ -938,12 +982,12 @@ pub fn build_proxy_command(
) -> String {
format!(
"{} ssh-proxy --gateway {} --sandbox {} --workspace {} --token {} --gateway-name {}",
shell_escape(exe),
shell_escape(gateway_url),
shell_escape(sandbox_name),
shell_escape(workspace),
shell_escape(token),
shell_escape(gateway_name),
proxy_command_escape(exe),
proxy_command_escape(gateway_url),
proxy_command_escape(sandbox_name),
proxy_command_escape(workspace),
proxy_command_escape(token),
proxy_command_escape(gateway_name),
)
}

Expand Down Expand Up @@ -1346,6 +1390,7 @@ mod tests {
}
}

#[cfg(not(windows))]
#[test]
fn build_proxy_command_escapes_shell_metacharacters() {
// Attacker-controlled values in every escapable position.
Expand All @@ -1369,6 +1414,7 @@ mod tests {
assert!(!outside_single_quotes(&cmd).contains('\n'));
}

#[cfg(not(windows))]
#[test]
fn build_proxy_command_empty_values_quote_rather_than_vanish() {
// An empty value must become `''` rather than disappearing — otherwise
Expand All @@ -1378,6 +1424,7 @@ mod tests {
assert!(cmd.contains("--workspace ''"));
}

#[cfg(not(windows))]
#[test]
fn build_proxy_command_safe_values_pass_through_unquoted() {
let cmd = build_proxy_command(
Expand Down Expand Up @@ -1411,8 +1458,39 @@ mod tests {
}
}

#[test]
fn windows_command_line_escape_round_trips_through_crt_parsing() {
assert_eq!(windows_command_line_escape(""), r#""""#);
assert_eq!(
windows_command_line_escape(r"C:\Program Files\openshell.exe"),
r#""C:\Program Files\openshell.exe""#
);
assert_eq!(windows_command_line_escape(r#"a"b"#), r#""a\"b""#);
assert_eq!(windows_command_line_escape(r#"a\"b"#), r#""a\\\"b""#);
assert_eq!(windows_command_line_escape(r"dir\"), r#""dir\\""#);
assert_eq!(windows_command_line_escape(r"a\b"), r#""a\b""#);
}

#[cfg(windows)]
#[test]
fn build_proxy_command_quotes_every_windows_argument() {
let cmd = build_proxy_command(
r"C:\Program Files\openshell.exe",
"gw",
"",
"ws & calc",
"tok",
"name",
);
assert_eq!(
cmd,
r#""C:\Program Files\openshell.exe" ssh-proxy --gateway "gw" --sandbox "" --workspace "ws & calc" --token "tok" --gateway-name "name""#
);
}

/// Helper: return the concatenation of characters that appear outside
/// POSIX single-quoted runs. Used by the metacharacter assertions above.
#[cfg(not(windows))]
fn outside_single_quotes(s: &str) -> String {
let mut out = String::new();
let mut inside = false;
Expand Down
Loading
Loading