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