isb_daemon/daemon/
policy.rs1use std::path::{Path, PathBuf};
23
24use crate::error::{Error, Result};
25use crate::spec::{ComposeFile, IdmapMode, IdmapSpec, MountType, PortBind, SandboxSpec};
26
27#[derive(Debug, Clone, Default)]
29pub struct RemotePolicy {
30 pub allow_privileged: bool,
31 pub allow_raw: bool,
33 pub bind_roots: Vec<PathBuf>,
35 pub publish_addresses: Vec<String>,
37 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 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 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 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 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 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 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 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}