Skip to main content

isb_daemon/daemon/
policy.rs

1//! What a remote caller may ask for.
2//!
3//! The incus socket is root on the host, and `isb serve` holds it. A caller
4//! through Cloudflare Access is trusted to run workloads, not to own the host,
5//! so its compose files and sandbox specs are checked here before anything
6//! reaches incus. Local callers (the unix socket, the same user as the daemon)
7//! skip all of this: they could run isb directly.
8//!
9//! Refused unless the operator opts in:
10//! - `privileged`, `raw_config`, `raw_devices`, `incus_profiles`, a custom
11//!   `idmap` map, and guest-bound proxies (a guest reaching into the host);
12//! - bind mounts outside `--bind-root` directories (none by default), and
13//!   symlinks that lead out of them;
14//! - publishing on anything but loopback, unless the address is listed in
15//!   `--publish-address`; unix-socket listeners;
16//! - a different `incus_project`;
17//! - secrets read from host files (pass their values instead).
18//!
19//! Interpolation for a remote caller sees only the variables it sent, never
20//! the daemon's environment.
21
22use std::path::{Path, PathBuf};
23
24use crate::error::{Error, Result};
25use crate::spec::{ComposeFile, IdmapMode, IdmapSpec, MountType, PortBind, SandboxSpec};
26
27/// The operator's limits for remote callers.
28#[derive(Debug, Clone, Default)]
29pub struct RemotePolicy {
30    pub allow_privileged: bool,
31    /// raw_config, raw_devices, incus_profiles, idmap maps, guest-bound ports.
32    pub allow_raw: bool,
33    /// Host directories bind mounts may come from.
34    pub bind_roots: Vec<PathBuf>,
35    /// Host addresses a published port may listen on, besides loopback.
36    pub publish_addresses: Vec<String>,
37    /// Exec into, remove and read logs of any instance, not only the ones
38    /// `isb serve` manages.
39    pub any_instance: bool,
40}
41
42fn refuse(what: impl std::fmt::Display) -> Error {
43    Error::invalid(format!(
44        "refused for a remote caller: {what} (see `isb serve --help` for the flag that allows it)"
45    ))
46}
47
48impl RemotePolicy {
49    /// Check a whole compose file. `base` is where its relative paths resolve.
50    pub fn check_file(&self, file: &ComposeFile, base: &Path) -> Result<()> {
51        if file.incus_project.is_some() {
52            return Err(refuse("incus_project"));
53        }
54        for (k, s) in &file.secrets {
55            if s.file.is_some() {
56                return Err(refuse(format!(
57                    "secret {k:?} from a host file; pass its value in `secrets`"
58                )));
59            }
60        }
61        for (name, spec) in &file.services {
62            self.check_spec(spec, base)
63                .map_err(|e| Error::invalid(format!("service {name:?}: {e}")))?;
64        }
65        Ok(())
66    }
67
68    /// Check one sandbox spec.
69    pub fn check_spec(&self, spec: &SandboxSpec, base: &Path) -> Result<()> {
70        if spec.privileged == Some(true) && !self.allow_privileged {
71            return Err(refuse("privileged"));
72        }
73        if !self.allow_raw {
74            if !spec.raw_config.is_empty() {
75                return Err(refuse("raw_config"));
76            }
77            if !spec.raw_devices.is_empty() {
78                return Err(refuse("raw_devices"));
79            }
80            if spec.profiles.is_some() {
81                return Err(refuse("incus_profiles"));
82            }
83            match &spec.idmap {
84                None | Some(IdmapSpec::Mode(IdmapMode::Auto | IdmapMode::None)) => {}
85                Some(_) => return Err(refuse("an idmap other than auto or none")),
86            }
87        }
88        // isb's own labels tie an instance to a stack and its balancer: set
89        // by hand, they would put a sandbox into another stack's rotation.
90        let deploy_labels = spec.deploy.as_ref().map(|d| &d.labels);
91        for k in spec
92            .labels
93            .keys()
94            .chain(deploy_labels.into_iter().flat_map(|l| l.keys()))
95        {
96            if k.starts_with("isb.") {
97                return Err(refuse(format!("label {k:?} (isb.* labels are isb's own)")));
98            }
99        }
100        for v in &spec.volumes {
101            if v.mount_type == MountType::Bind {
102                self.check_bind(&v.source, base)?;
103            }
104        }
105        for p in &spec.ports {
106            if p.bind == PortBind::Guest {
107                if !self.allow_raw {
108                    return Err(refuse(
109                        "a guest-bound port (bind: guest) reaching into the host",
110                    ));
111                }
112                continue;
113            }
114            let listen =
115                crate::plan::normalize_addr(&p.listen, "127.0.0.1").map_err(Error::invalid)?;
116            if listen.starts_with("unix:") {
117                return Err(refuse("a unix-socket listener on the host"));
118            }
119            let host = crate::plan::split_addr(&listen)
120                .map(|(_, h, _)| h.to_string())
121                .or_else(|| {
122                    // A range: tcp:HOST:8000-8010.
123                    listen.split(':').nth(1).map(String::from)
124                })
125                .unwrap_or_default();
126            let h = host.trim_start_matches('[').trim_end_matches(']');
127            let loopback = h
128                .parse::<std::net::IpAddr>()
129                .is_ok_and(|ip| ip.is_loopback());
130            if !loopback && !self.publish_addresses.iter().any(|a| a == h) {
131                return Err(refuse(format!("publishing on {h}")));
132            }
133        }
134        Ok(())
135    }
136
137    /// A bind source must lie inside a bind root once every symlink is
138    /// followed.
139    fn check_bind(&self, source: &str, base: &Path) -> Result<()> {
140        if self.bind_roots.is_empty() {
141            return Err(refuse(format!(
142                "bind mount {source:?} (no --bind-root is set)"
143            )));
144        }
145        let p = crate::plan::resolve_host_path(source, base)?;
146        let real = std::fs::canonicalize(&p)
147            .map_err(|e| Error::invalid(format!("bind mount {p}: {e}")))?;
148        let inside = self
149            .bind_roots
150            .iter()
151            .any(|r| std::fs::canonicalize(r).is_ok_and(|root| real.starts_with(&root)));
152        if !inside {
153            return Err(refuse(format!(
154                "bind mount {} (outside every --bind-root)",
155                real.display()
156            )));
157        }
158        Ok(())
159    }
160
161    /// A base directory a remote caller asked for must be inside a bind root.
162    pub fn check_base_dir(&self, dir: &Path) -> Result<()> {
163        self.check_bind(&dir.to_string_lossy(), Path::new("/"))
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn spec(y: &str) -> SandboxSpec {
172        serde_yaml_ng::from_str(y).unwrap()
173    }
174
175    #[test]
176    fn refuses_host_escapes() {
177        let p = RemotePolicy::default();
178        let base = Path::new("/");
179        assert!(
180            p.check_spec(&spec("image: x\nprivileged: true\n"), base)
181                .is_err()
182        );
183        assert!(
184            p.check_spec(&spec("image: x\nraw_config: {a: b}\n"), base)
185                .is_err()
186        );
187        assert!(
188            p.check_spec(&spec("image: x\nraw_devices: {d: {type: disk}}\n"), base)
189                .is_err()
190        );
191        assert!(
192            p.check_spec(&spec("image: x\nincus_profiles: [default]\n"), base)
193                .is_err()
194        );
195        assert!(
196            p.check_spec(&spec("image: x\nvolumes: ['/etc:/x']\n"), base)
197                .is_err()
198        );
199        assert!(
200            p.check_spec(&spec("image: x\nports: ['0.0.0.0:80:80']\n"), base)
201                .is_err()
202        );
203        assert!(
204            p.check_spec(
205                &spec(
206                    "image: x\nports: [{listen: 'unix:/run/x.sock', connect: 'tcp:127.0.0.1:1'}]\n"
207                ),
208                base
209            )
210            .is_err()
211        );
212        assert!(
213            p.check_spec(
214                &spec("image: x\nports: [{listen: 'tcp:127.0.0.1:1', connect: 'unix:/var/lib/incus/unix.socket', bind: guest}]\n"),
215                base
216            )
217            .is_err()
218        );
219        assert!(
220            p.check_spec(&spec("image: x\nidmap: {raw: 'both 0 0'}\n"), base)
221                .is_err()
222        );
223        assert!(
224            p.check_spec(&spec("image: x\nlabels: {isb.stack: app}\n"), base)
225                .is_err()
226        );
227        assert!(
228            p.check_spec(&spec("image: x\ndeploy: {labels: {isb.rev: x}}\n"), base)
229                .is_err()
230        );
231        // Fine: loopback ports, named volumes, idmap auto.
232        p.check_spec(
233            &spec("image: x\nidmap: auto\nports: ['8080:80', '[::1]:81:81']\nvolumes: ['data:/data']\n"),
234            base,
235        )
236        .unwrap();
237    }
238
239    #[test]
240    fn publish_addresses_and_bind_roots() {
241        let dir = tempfile::tempdir().unwrap();
242        std::fs::create_dir(dir.path().join("app")).unwrap();
243        std::os::unix::fs::symlink("/etc", dir.path().join("app/escape")).unwrap();
244        let p = RemotePolicy {
245            bind_roots: vec![dir.path().to_path_buf()],
246            publish_addresses: vec!["100.86.22.100".into()],
247            ..Default::default()
248        };
249        let base = dir.path();
250        p.check_spec(
251            &spec("image: x\nvolumes: ['./app:/app']\nports: ['100.86.22.100:80:80']\n"),
252            base,
253        )
254        .unwrap();
255        let e = p
256            .check_spec(&spec("image: x\nvolumes: ['./app/escape:/x']\n"), base)
257            .unwrap_err()
258            .to_string();
259        assert!(e.contains("outside"), "{e}");
260        assert!(
261            p.check_spec(&spec("image: x\nports: ['10.0.0.1:80:80']\n"), base)
262                .is_err()
263        );
264    }
265
266    #[test]
267    fn file_level_checks() {
268        let p = RemotePolicy::default();
269        let f: ComposeFile = serde_yaml_ng::from_str(
270            "secrets: {k: {file: /home/u/.ssh/id_ed25519}}\nservices: {}\n",
271        )
272        .unwrap();
273        assert!(p.check_file(&f, Path::new("/")).is_err());
274        let f: ComposeFile =
275            serde_yaml_ng::from_str("incus_project: other\nservices: {}\n").unwrap();
276        assert!(p.check_file(&f, Path::new("/")).is_err());
277    }
278}