use crate::launch::{Environment, LaunchOptions};
use crate::protocol::resolver::{GetParams, GetResult, Representation};
use crate::{Error, Result};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum FilesystemAccess {
#[default]
Remote,
Shared,
}
impl FilesystemAccess {
pub(crate) fn prepare_get(self, params: &GetParams) -> Result<Cow<'_, GetParams>> {
params.validate()?;
if self == Self::Shared {
return Ok(Cow::Borrowed(params));
}
match params.representation {
Representation::Path => Err(Error::Protocol(
"path resolution requires an explicitly shared filesystem",
)),
Representation::Auto => {
let mut params = params.clone();
params.representation = Representation::Value;
Ok(Cow::Owned(params))
}
Representation::Value => Ok(Cow::Borrowed(params)),
}
}
pub(crate) fn accepts(self, result: &GetResult) -> bool {
self == Self::Shared || !matches!(result, GetResult::Path(_))
}
}
#[derive(Debug, Clone)]
pub struct SshOptions {
pub executable: PathBuf,
pub environment: Environment,
pub destination: String,
pub remote_executable: String,
pub read_only: bool,
pub filesystem: FilesystemAccess,
}
impl SshOptions {
pub fn new(destination: impl Into<String>) -> Self {
Self {
executable: PathBuf::from("ssh"),
environment: Environment::Inherit(BTreeMap::new()),
destination: destination.into(),
remote_executable: "secretspec".into(),
read_only: true,
filesystem: FilesystemAccess::Remote,
}
}
pub(crate) fn launch_options(&self) -> Result<LaunchOptions> {
if self.destination.is_empty()
|| self.destination.starts_with('-')
|| !self
.destination
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-_@:/[]".contains(&byte))
{
return Err(Error::Protocol("invalid SSH destination"));
}
if self.remote_executable.is_empty()
|| self.remote_executable.starts_with('-')
|| self.remote_executable.chars().any(char::is_control)
{
return Err(Error::Protocol("invalid remote resolver executable"));
}
let executable = self.remote_executable.replace('\'', "'\\''");
let command = format!(
"exec '{executable}' serve{}",
if self.read_only { " --read-only" } else { "" }
);
let mut arguments: Vec<_> = [
"-T",
"-oIgnoreUnknown=SessionType,StdinNull,ForkAfterAuthentication",
"-oBatchMode=yes",
"-oStrictHostKeyChecking=yes",
"-oClearAllForwardings=yes",
"-oForwardAgent=no",
"-oForwardX11=no",
"-oPermitLocalCommand=no",
"-oRemoteCommand=none",
"-oSessionType=default",
"-oStdinNull=no",
"-oForkAfterAuthentication=no",
"-oServerAliveInterval=15",
"-oServerAliveCountMax=3",
"--",
]
.into_iter()
.map(Into::into)
.collect();
arguments.push(self.destination.clone().into());
arguments.push(command.into());
let options = LaunchOptions {
executable: self.executable.clone(),
arguments,
environment: self.environment.clone(),
allow_path_discovery: !self.executable.is_absolute(),
max_stderr_bytes: 64 * 1024,
};
options.validate()?;
Ok(options)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::resolver::{InitializeApplication, Manifest};
#[test]
fn ssh_rejects_option_injection_and_control_characters() {
for destination in [
"",
"-oProxyCommand=command",
"host name",
"host\ncommand",
"host;command",
"host$(command)",
] {
assert!(SshOptions::new(destination).launch_options().is_err());
}
for destination in [
"developer",
"alice@host",
"alice@[::1]",
"ssh://alice@host:2222",
] {
assert!(SshOptions::new(destination).launch_options().is_ok());
}
let mut options = SshOptions::new("developer");
options.remote_executable = "resolver\ncommand".into();
assert!(options.launch_options().is_err());
}
#[test]
fn ssh_tolerates_clients_older_than_openssh_8_7() {
let arguments: Vec<String> = SshOptions::new("developer")
.launch_options()
.unwrap()
.arguments
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect();
let position = |argument: &str| {
arguments
.iter()
.position(|candidate| candidate == argument)
.unwrap_or_else(|| panic!("{argument} missing from {arguments:?}"))
};
let ignore = position("-oIgnoreUnknown=SessionType,StdinNull,ForkAfterAuthentication");
for newer in [
"-oSessionType=default",
"-oStdinNull=no",
"-oForkAfterAuthentication=no",
] {
assert!(ignore < position(newer), "{newer} precedes IgnoreUnknown");
}
}
#[test]
fn client_validates_paths_in_the_resolvers_namespace() {
let mut application = InitializeApplication {
manifest: Manifest::Path {
path: String::new(),
},
provider: None,
profile: None,
scope: None,
reason: None,
requested_authorization_duration_ms: None,
};
for path in [
"/srv/project/secretspec.toml",
"C:\\project\\secretspec.toml",
"\\\\server\\share\\secretspec.toml",
] {
application.manifest = Manifest::Path { path: path.into() };
application.validate_for_connection().unwrap();
}
for path in [
"project/secretspec.toml",
"C:relative",
"/srv/../secret",
"C:\\project\\..\\secret",
"/srv/./secret",
"/srv/secret\0",
] {
application.manifest = Manifest::Path { path: path.into() };
assert!(application.validate_for_connection().is_err(), "{path:?}");
}
application.manifest = Manifest::Inline {
toml: String::new(),
base_dir: "relative".into(),
};
assert!(application.validate_for_connection().is_err());
}
}