use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::spec::{ComposeFile, IdmapMode, IdmapSpec, MountType, PortBind, SandboxSpec};
#[derive(Debug, Clone, Default)]
pub struct RemotePolicy {
pub allow_privileged: bool,
pub allow_raw: bool,
pub bind_roots: Vec<PathBuf>,
pub publish_addresses: Vec<String>,
pub any_instance: bool,
}
fn refuse(what: impl std::fmt::Display) -> Error {
Error::invalid(format!(
"refused for a remote caller: {what} (see `isb serve --help` for the flag that allows it)"
))
}
impl RemotePolicy {
pub fn check_file(&self, file: &ComposeFile, base: &Path) -> Result<()> {
if file.incus_project.is_some() {
return Err(refuse("incus_project"));
}
for (k, s) in &file.secrets {
if s.file.is_some() {
return Err(refuse(format!(
"secret {k:?} from a host file; pass its value in `secrets`"
)));
}
}
for (name, spec) in &file.services {
self.check_spec(spec, base)
.map_err(|e| Error::invalid(format!("service {name:?}: {e}")))?;
}
Ok(())
}
pub fn check_spec(&self, spec: &SandboxSpec, base: &Path) -> Result<()> {
if spec.privileged == Some(true) && !self.allow_privileged {
return Err(refuse("privileged"));
}
if !self.allow_raw {
if !spec.raw_config.is_empty() {
return Err(refuse("raw_config"));
}
let root_size_only = spec
.raw_devices
.iter()
.all(|(name, props)| name == "root" && props.keys().all(|k| k == "size"));
if !root_size_only {
return Err(refuse("raw_devices"));
}
if spec.profiles.is_some() {
return Err(refuse("incus_profiles"));
}
match &spec.idmap {
None | Some(IdmapSpec::Mode(IdmapMode::Auto | IdmapMode::None)) => {}
Some(_) => return Err(refuse("an idmap other than auto or none")),
}
}
let deploy_labels = spec.deploy.as_ref().map(|d| &d.labels);
for k in spec
.labels
.keys()
.chain(deploy_labels.into_iter().flat_map(|l| l.keys()))
{
if k.starts_with("isb.") {
return Err(refuse(format!("label {k:?} (isb.* labels are isb's own)")));
}
}
for v in &spec.volumes {
if v.mount_type == MountType::Bind {
self.check_bind(&v.source, base)?;
}
}
for p in &spec.ports {
if p.bind == PortBind::Guest {
if !self.allow_raw {
return Err(refuse(
"a guest-bound port (bind: guest) reaching into the host",
));
}
continue;
}
let listen =
crate::plan::normalize_addr(&p.listen, "127.0.0.1").map_err(Error::invalid)?;
if listen.starts_with("unix:") {
return Err(refuse("a unix-socket listener on the host"));
}
let host = crate::plan::split_addr(&listen)
.map(|(_, h, _)| h.to_string())
.or_else(|| {
listen.split(':').nth(1).map(String::from)
})
.unwrap_or_default();
let h = host.trim_start_matches('[').trim_end_matches(']');
let loopback = h
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !loopback && !self.publish_addresses.iter().any(|a| a == h) {
return Err(refuse(format!("publishing on {h}")));
}
}
Ok(())
}
fn check_bind(&self, source: &str, base: &Path) -> Result<()> {
if self.bind_roots.is_empty() {
return Err(refuse(format!(
"bind mount {source:?} (no --bind-root is set)"
)));
}
let p = crate::plan::resolve_host_path(source, base)?;
let real = std::fs::canonicalize(&p)
.map_err(|e| Error::invalid(format!("bind mount {p}: {e}")))?;
let inside = self
.bind_roots
.iter()
.any(|r| std::fs::canonicalize(r).is_ok_and(|root| real.starts_with(&root)));
if !inside {
return Err(refuse(format!(
"bind mount {} (outside every --bind-root)",
real.display()
)));
}
Ok(())
}
pub fn check_base_dir(&self, dir: &Path) -> Result<()> {
self.check_bind(&dir.to_string_lossy(), Path::new("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(y: &str) -> SandboxSpec {
serde_yaml_ng::from_str(y).unwrap()
}
#[test]
fn refuses_host_escapes() {
let p = RemotePolicy::default();
let base = Path::new("/");
assert!(
p.check_spec(&spec("image: x\nprivileged: true\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nraw_config: {a: b}\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nraw_devices: {d: {type: disk}}\n"), base)
.is_err()
);
assert!(
p.check_spec(
&spec("image: x\nraw_devices: {root: {size: 20GiB}}\n"),
base
)
.is_ok()
);
assert!(
p.check_spec(
&spec("image: x\nraw_devices: {root: {size: 20GiB, pool: other}}\n"),
base
)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nincus_profiles: [default]\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nvolumes: ['/etc:/x']\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nports: ['0.0.0.0:80:80']\n"), base)
.is_err()
);
assert!(
p.check_spec(
&spec(
"image: x\nports: [{listen: 'unix:/run/x.sock', connect: 'tcp:127.0.0.1:1'}]\n"
),
base
)
.is_err()
);
assert!(
p.check_spec(
&spec("image: x\nports: [{listen: 'tcp:127.0.0.1:1', connect: 'unix:/var/lib/incus/unix.socket', bind: guest}]\n"),
base
)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nidmap: {raw: 'both 0 0'}\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\nlabels: {isb.stack: app}\n"), base)
.is_err()
);
assert!(
p.check_spec(&spec("image: x\ndeploy: {labels: {isb.rev: x}}\n"), base)
.is_err()
);
p.check_spec(
&spec("image: x\nidmap: auto\nports: ['8080:80', '[::1]:81:81']\nvolumes: ['data:/data']\n"),
base,
)
.unwrap();
}
#[test]
fn publish_addresses_and_bind_roots() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("app")).unwrap();
std::os::unix::fs::symlink("/etc", dir.path().join("app/escape")).unwrap();
let p = RemotePolicy {
bind_roots: vec![dir.path().to_path_buf()],
publish_addresses: vec!["100.86.22.100".into()],
..Default::default()
};
let base = dir.path();
p.check_spec(
&spec("image: x\nvolumes: ['./app:/app']\nports: ['100.86.22.100:80:80']\n"),
base,
)
.unwrap();
let e = p
.check_spec(&spec("image: x\nvolumes: ['./app/escape:/x']\n"), base)
.unwrap_err()
.to_string();
assert!(e.contains("outside"), "{e}");
assert!(
p.check_spec(&spec("image: x\nports: ['10.0.0.1:80:80']\n"), base)
.is_err()
);
}
#[test]
fn file_level_checks() {
let p = RemotePolicy::default();
let f: ComposeFile = serde_yaml_ng::from_str(
"secrets: {k: {file: /home/u/.ssh/id_ed25519}}\nservices: {}\n",
)
.unwrap();
assert!(p.check_file(&f, Path::new("/")).is_err());
let f: ComposeFile =
serde_yaml_ng::from_str("incus_project: other\nservices: {}\n").unwrap();
assert!(p.check_file(&f, Path::new("/")).is_err());
}
}