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