1use crate::launch::{Environment, LaunchOptions};
7use crate::protocol::resolver::{GetParams, GetResult, Representation};
8use crate::{Error, Result};
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
15pub enum FilesystemAccess {
16 #[default]
18 Remote,
19 Shared,
21}
22
23impl FilesystemAccess {
24 pub(crate) fn prepare_get(self, params: &GetParams) -> Result<Cow<'_, GetParams>> {
25 params.validate()?;
26 if self == Self::Shared {
27 return Ok(Cow::Borrowed(params));
28 }
29 match params.representation {
30 Representation::Path => Err(Error::Protocol(
31 "path resolution requires an explicitly shared filesystem",
32 )),
33 Representation::Auto => {
34 let mut params = params.clone();
35 params.representation = Representation::Value;
38 Ok(Cow::Owned(params))
39 }
40 Representation::Value => Ok(Cow::Borrowed(params)),
41 }
42 }
43
44 pub(crate) fn accepts(self, result: &GetResult) -> bool {
45 self == Self::Shared || !matches!(result, GetResult::Path(_))
46 }
47}
48
49#[derive(Debug, Clone)]
56pub struct SshOptions {
57 pub executable: PathBuf,
59 pub environment: Environment,
61 pub destination: String,
63 pub remote_executable: String,
65 pub read_only: bool,
67 pub filesystem: FilesystemAccess,
68}
69
70impl SshOptions {
71 pub fn new(destination: impl Into<String>) -> Self {
73 Self {
74 executable: PathBuf::from("ssh"),
75 environment: Environment::Inherit(BTreeMap::new()),
76 destination: destination.into(),
77 remote_executable: "secretspec".into(),
78 read_only: true,
79 filesystem: FilesystemAccess::Remote,
80 }
81 }
82
83 pub(crate) fn launch_options(&self) -> Result<LaunchOptions> {
84 if self.destination.is_empty()
85 || self.destination.starts_with('-')
86 || !self
87 .destination
88 .bytes()
89 .all(|byte| byte.is_ascii_alphanumeric() || b".-_@:/[]".contains(&byte))
90 {
91 return Err(Error::Protocol("invalid SSH destination"));
92 }
93 if self.remote_executable.is_empty()
94 || self.remote_executable.starts_with('-')
95 || self.remote_executable.chars().any(char::is_control)
96 {
97 return Err(Error::Protocol("invalid remote resolver executable"));
98 }
99 let executable = self.remote_executable.replace('\'', "'\\''");
102 let command = format!(
103 "exec '{executable}' serve{}",
104 if self.read_only { " --read-only" } else { "" }
105 );
106 let mut arguments: Vec<_> = [
107 "-T",
108 "-oIgnoreUnknown=SessionType,StdinNull,ForkAfterAuthentication",
114 "-oBatchMode=yes",
115 "-oStrictHostKeyChecking=yes",
116 "-oClearAllForwardings=yes",
117 "-oForwardAgent=no",
118 "-oForwardX11=no",
119 "-oPermitLocalCommand=no",
120 "-oRemoteCommand=none",
121 "-oSessionType=default",
122 "-oStdinNull=no",
123 "-oForkAfterAuthentication=no",
124 "-oServerAliveInterval=15",
125 "-oServerAliveCountMax=3",
126 "--",
127 ]
128 .into_iter()
129 .map(Into::into)
130 .collect();
131 arguments.push(self.destination.clone().into());
132 arguments.push(command.into());
133 let options = LaunchOptions {
134 executable: self.executable.clone(),
135 arguments,
136 environment: self.environment.clone(),
137 allow_path_discovery: !self.executable.is_absolute(),
138 max_stderr_bytes: 64 * 1024,
139 };
140 options.validate()?;
141 Ok(options)
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::protocol::resolver::{InitializeApplication, Manifest};
149
150 #[test]
151 fn ssh_rejects_option_injection_and_control_characters() {
152 for destination in [
153 "",
154 "-oProxyCommand=command",
155 "host name",
156 "host\ncommand",
157 "host;command",
158 "host$(command)",
159 ] {
160 assert!(SshOptions::new(destination).launch_options().is_err());
161 }
162 for destination in [
163 "developer",
164 "alice@host",
165 "alice@[::1]",
166 "ssh://alice@host:2222",
167 ] {
168 assert!(SshOptions::new(destination).launch_options().is_ok());
169 }
170 let mut options = SshOptions::new("developer");
171 options.remote_executable = "resolver\ncommand".into();
172 assert!(options.launch_options().is_err());
173 }
174
175 #[test]
176 fn ssh_tolerates_clients_older_than_openssh_8_7() {
177 let arguments: Vec<String> = SshOptions::new("developer")
178 .launch_options()
179 .unwrap()
180 .arguments
181 .iter()
182 .map(|argument| argument.to_string_lossy().into_owned())
183 .collect();
184 let position = |argument: &str| {
185 arguments
186 .iter()
187 .position(|candidate| candidate == argument)
188 .unwrap_or_else(|| panic!("{argument} missing from {arguments:?}"))
189 };
190 let ignore = position("-oIgnoreUnknown=SessionType,StdinNull,ForkAfterAuthentication");
191 for newer in [
192 "-oSessionType=default",
193 "-oStdinNull=no",
194 "-oForkAfterAuthentication=no",
195 ] {
196 assert!(ignore < position(newer), "{newer} precedes IgnoreUnknown");
197 }
198 }
199
200 #[test]
201 fn client_validates_paths_in_the_resolvers_namespace() {
202 let mut application = InitializeApplication {
203 manifest: Manifest::Path {
204 path: String::new(),
205 },
206 provider: None,
207 profile: None,
208 scope: None,
209 reason: None,
210 requested_authorization_duration_ms: None,
211 };
212 for path in [
213 "/srv/project/secretspec.toml",
214 "C:\\project\\secretspec.toml",
215 "\\\\server\\share\\secretspec.toml",
216 ] {
217 application.manifest = Manifest::Path { path: path.into() };
218 application.validate_for_connection().unwrap();
219 }
220 for path in [
221 "project/secretspec.toml",
222 "C:relative",
223 "/srv/../secret",
224 "C:\\project\\..\\secret",
225 "/srv/./secret",
226 "/srv/secret\0",
227 ] {
228 application.manifest = Manifest::Path { path: path.into() };
229 assert!(application.validate_for_connection().is_err(), "{path:?}");
230 }
231 application.manifest = Manifest::Inline {
232 toml: String::new(),
233 base_dir: "relative".into(),
234 };
235 assert!(application.validate_for_connection().is_err());
236 }
237}