blob: 50a66f8cf7c808b312a039af8f5ce4f871ae8d53 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#![cfg(target_os = "linux")]
use maka_sandbox::{
Network, Sandbox,
filesystem::{Access, Policy, Rule},
launch::Launch,
};
use std::{
fs,
net::{TcpListener, TcpStream},
os::{fd::AsRawFd, unix::process::CommandExt},
path::Path,
process::{Command, Output},
};
struct Prepared {
command: Option<Command>,
lease: Option<maka_sandbox::launch::MountLease>,
}
impl std::ops::Deref for Prepared {
type Target = Command;
fn deref(&self) -> &Command {
self.command.as_ref().unwrap()
}
}
impl std::ops::DerefMut for Prepared {
fn deref_mut(&mut self) -> &mut Command {
self.command.as_mut().unwrap()
}
}
impl Drop for Prepared {
fn drop(&mut self) {
self.command.take();
if let Some(lease) = self.lease.take() {
lease.finish().unwrap();
}
}
}
fn command(policy: &Policy, network: Network, cwd: &Path, executable: &Path) -> Prepared {
let Launch::Wrapped {
program,
args,
files,
lease,
} = Sandbox::Managed {
filesystem: policy.clone(),
network,
}
.prepare(cwd)
.unwrap()
else {
panic!("managed execution must not bypass isolation")
};
let mut command = Command::new(program);
command
.args(args)
.arg(executable)
.current_dir(cwd)
.env_clear();
// Launch descriptors must survive only the wrapper's exec, not leak from
// the multithreaded parent. Production pipe and PTY owners do the same.
unsafe {
command.pre_exec(move || {
for file in &files {
if libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
});
}
Prepared {
command: Some(command),
lease: Some(lease),
}
}
#[test]
fn bubblewrap_enforces_mount_boundaries_and_network_policy() {
const CONNECT: &str = "MAKA_SANDBOX_TEST_CONNECT";
// Re-enter the same test binary as the sandboxed network client; no Python,
// curl or distro-specific shell networking extensions are needed.
if let Ok(address) = std::env::var(CONNECT) {
let result = TcpStream::connect_timeout(
&address.parse().unwrap(),
std::time::Duration::from_secs(2),
);
std::process::exit(if result.is_ok() { 0 } else { 1 });
}
// Real workspaces commonly live on a different filesystem from /tmp.
let directory = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let temporary = tempfile::tempdir().unwrap();
let other_filesystem = temporary
.path()
.canonicalize()
.unwrap()
.join("missing-secret");
let root = directory.path().canonicalize().unwrap();
let workspace = root.join("workspace");
let protected = workspace.join("protected");
fs::create_dir_all(protected.join("output")).unwrap();
fs::write(protected.join("secret"), "private").unwrap();
fs::write(protected.join("config"), "original").unwrap();
fs::write(root.join("outside"), "outside").unwrap();
std::os::unix::fs::symlink(root.join("outside"), workspace.join("alias")).unwrap();
let policy = Policy {
default: Access::Read,
rules: vec![
Rule::subtree(&workspace, Access::Write),
Rule::subtree(&protected, Access::Read),
Rule::exact(protected.join("secret"), Access::Deny),
Rule::subtree(protected.join("output"), Access::Write),
Rule::subtree(workspace.join(".git"), Access::Read),
Rule::subtree(workspace.join(".agents"), Access::Read),
Rule::exact(workspace.join("missing-secret"), Access::Deny),
Rule::subtree(workspace.join("new/deep/.git"), Access::Read),
Rule::exact(&other_filesystem, Access::Deny),
],
deny_globs: vec![],
};
let run = |script: &str| -> Output {
command(&policy, Network::Denied, &workspace, Path::new("/bin/sh"))
.args(["-c", script])
.output()
.unwrap()
};
let allowed = run("printf accepted > ordinary; printf nested > protected/output/accepted");
assert!(
allowed.status.success(),
"{}",
String::from_utf8_lossy(&allowed.stderr)
);
for script in [
"printf bad > ../outside",
"printf bad > alias",
"printf bad > protected/config",
"cat protected/secret",
"mv protected moved",
"ln protected/config hardlink && printf bad > hardlink",
"ln protected/secret leaked && cat leaked",
"printf bad > .git/config",
"rmdir .agents",
"cat missing-secret",
"printf bad > new/deep/.git/config",
"mv new relocated",
] {
let blocked = run(script);
assert!(!blocked.status.success(), "{script}");
}
assert_eq!(
fs::read_to_string(workspace.join("ordinary")).unwrap(),
"accepted"
);
assert_eq!(
fs::read_to_string(protected.join("config")).unwrap(),
"original"
);
assert_eq!(fs::read_to_string(root.join("outside")).unwrap(), "outside");
for name in [".git", ".agents", "missing-secret", "new"] {
assert!(!workspace.join(name).exists(), "cleanup left {name}");
}
assert!(!other_filesystem.exists());
let mut unsupported = policy.clone();
unsupported.rules.push(Rule::subtree(
root.join("z-missing/descendant"),
Access::Write,
));
assert!(
Sandbox::Managed {
filesystem: unsupported,
network: Network::Denied
}
.prepare(&workspace)
.is_err()
);
assert!(
!workspace.join(".git").exists(),
"failed preparation must roll back its placeholders"
);
// Two prepared/running commands share an inode lease, not ownership inferred
// from the current emptiness of a directory. Finishing either cannot remove
// another live sandbox's mount target.
let first = command(&policy, Network::Denied, &workspace, Path::new("/bin/sh"));
let second = command(
&policy,
Network::Denied,
&std::env::temp_dir().canonicalize().unwrap(),
Path::new("/bin/sh"),
);
drop(first);
assert!(workspace.join(".git").is_dir());
assert!(workspace.join("new/deep/.git").is_dir());
assert!(other_filesystem.is_file());
drop(second);
assert!(!workspace.join(".git").exists());
assert!(!workspace.join("new").exists());
assert!(!other_filesystem.exists());
let output = run("printf preserve > new/user-file");
assert!(output.status.success(), "{output:?}");
assert!(!workspace.join("new/deep").exists());
assert_eq!(
fs::read_to_string(workspace.join("new/user-file")).unwrap(),
"preserve"
);
// Even when an owner fails before native spawn, recovery only removes its
// recorded empty inode. A real replacement directory must survive.
let mut abandoned = command(&policy, Network::Denied, &workspace, Path::new("/bin/sh"));
let lease = abandoned.lease.take().unwrap();
abandoned.command.take();
drop(abandoned);
drop(lease);
fs::remove_dir(workspace.join(".git")).unwrap();
fs::create_dir(workspace.join(".git")).unwrap();
fs::write(workspace.join(".git/user-data"), "preserve").unwrap();
drop(command(
&policy,
Network::Denied,
&workspace,
Path::new("/bin/sh"),
));
assert_eq!(
fs::read_to_string(workspace.join(".git/user-data")).unwrap(),
"preserve"
);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
for network in [Network::Denied, Network::Allowed] {
let result = command(
&policy,
network.clone(),
&workspace,
&std::env::current_exe().unwrap(),
)
.args([
"--exact",
"bubblewrap_enforces_mount_boundaries_and_network_policy",
"--nocapture",
])
.env(CONNECT, listener.local_addr().unwrap().to_string())
.output()
.unwrap();
assert_eq!(
result.status.success(),
network == Network::Allowed,
"{}",
String::from_utf8_lossy(&result.stderr)
);
}
}
#[test]
fn glob_snapshot_masks_existing_secrets_and_refreshes_on_the_next_launch() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
fs::create_dir_all(root.join(".hidden/private/nested")).unwrap();
fs::write(root.join(".hidden/private/nested/secret"), "untouched").unwrap();
fs::write(root.join("target"), "untouched").unwrap();
std::os::unix::fs::symlink(root.join("target"), root.join("key.secret")).unwrap();
let policy = Policy {
default: Access::Read,
rules: vec![
Rule::subtree(&root, Access::Write),
Rule::subtree(root.join(".hidden/private/nested"), Access::Write),
],
deny_globs: vec![
format!("{}/**/private", root.display()),
format!("{}/*.secret", root.display()),
],
};
let first = command(&policy, Network::Denied, &root, Path::new("/bin/sh"))
.args(["-c", "if cat .hidden/private/nested/secret || cat key.secret || cat target; then exit 91; fi; printf fresh > new.secret"])
.output().unwrap();
assert!(first.status.success(), "{first:?}");
assert_eq!(
fs::read_to_string(root.join("new.secret")).unwrap(),
"fresh"
);
let second = command(&policy, Network::Denied, &root, Path::new("/bin/sh"))
.args(["-c", "cat new.secret"])
.output()
.unwrap();
assert!(!second.status.success(), "{second:?}");
assert_eq!(
fs::read_to_string(root.join("target")).unwrap(),
"untouched"
);
}