Skip to main content

secretspec_ipc/
connection.rs

1//! Resolver connection configuration (0.21+).
2//!
3//! Transport authentication is established before the IPC handshake. Nothing
4//! in resolver initialization authenticates a caller or grants filesystem access.
5
6use 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/// Whether resolver-owned paths are usable by this client (0.21+).
14#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
15pub enum FilesystemAccess {
16    /// Separate filesystems. Only inline values may cross this connection.
17    #[default]
18    Remote,
19    /// The caller explicitly guarantees identical paths and access permissions.
20    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                // Asking for Value makes the resolver reject as_path before
36                // resolving it or creating a file. It never exports its contents.
37                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/// Launch a private resolver through OpenSSH (0.21+).
50///
51/// Configuration must come from the application or user, never an untrusted
52/// project. The remote account is the authorization boundary; this does not
53/// sandbox the resolver to a project. Authentication and host-key enrollment
54/// must already be configured. The remote login shell must support POSIX quoting.
55#[derive(Debug, Clone)]
56pub struct SshOptions {
57    /// Local OpenSSH executable. Privileged callers must use an absolute path.
58    pub executable: PathBuf,
59    /// Local SSH process environment. Privileged hosts should use an allowlist.
60    pub environment: Environment,
61    /// Trusted SSH host alias, hostname, or user@host.
62    pub destination: String,
63    /// Remote executable, interpreted on the resolver machine, not locally.
64    pub remote_executable: String,
65    /// Refuse mutation methods and resolution that would persist a new secret.
66    pub read_only: bool,
67    pub filesystem: FilesystemAccess,
68}
69
70impl SshOptions {
71    /// Use existing SSH configuration, read-only access, and separate filesystems.
72    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        // SSH sends a command string to the remote shell, even when launched
100        // locally with argv. Quote the executable as one POSIX shell word.
101        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            // OpenSSH 8.7 made -N, -n, and -f settable from ssh_config as
109            // SessionType, StdinNull, and ForkAfterAuthentication, so they are
110            // pinned below. Older clients reject those keywords; they also
111            // cannot read them from ssh_config, so ignoring them there is safe.
112            // IgnoreUnknown (OpenSSH 6.3+) only covers options after it.
113            "-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}