Confine package ownership changes to private mounts

This commit is contained in:
ms2k 2026-07-27 03:31:58 +09:00
commit 8334fd379f
8 changed files with 402 additions and 25 deletions

View file

@ -275,8 +275,23 @@ impl IgnoredWorkspace {
let stat = match backing {
Classification::Host => host,
Classification::Isolated => isolated,
}
.ok_or_else(|| io::Error::from_raw_os_error(libc::ENOENT))?;
};
let (backing, stat) = if let Some(stat) = stat {
(backing, stat)
} else if backing == Classification::Isolated
&& host.as_ref().is_some_and(|stat| {
stat.st_mode & libc::S_IFMT == libc::S_IFDIR
&& self
.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.matches(path, &crate::migration::ObjectStamp::from_stat(stat))
})
{
(Classification::Host, host.expect("checked host directory"))
} else {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
};
Ok(ResolvedEntry {
path: path.to_path_buf(),
backing,

View file

@ -81,6 +81,63 @@ fn disabled_isolation_routes_ignored_paths_to_the_host_backing() {
);
}
#[test]
fn newly_ignored_created_directory_remains_a_safe_traversal_shell() {
let fixture = Fixture::new();
repository(&fixture.workspace);
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
for path in ["environment", "environment/bin"] {
fs::create_dir(fixture.workspace.join(path)).unwrap();
filesystem
.record_created(Classification::Host, std::path::Path::new(path))
.unwrap();
}
fs::write(fixture.workspace.join("environment/.gitignore"), b"*\n").unwrap();
filesystem
.record_created(
Classification::Host,
std::path::Path::new("environment/.gitignore"),
)
.unwrap();
let path = std::path::Path::new("environment/bin");
let snapshot = filesystem.classifier.snapshot_for(path);
let entry = filesystem.resolve(&snapshot, path).unwrap();
assert_eq!(entry.backing, Classification::Host);
assert_eq!(
filesystem.classify(&snapshot, &path.join("python3"), false),
Classification::Isolated
);
}
#[test]
fn ignored_host_directory_requires_current_creation_provenance() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"hidden/\nchanged/\n").unwrap();
fs::create_dir(fixture.workspace.join("hidden")).unwrap();
fs::create_dir(fixture.workspace.join("changed")).unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
let hidden = std::path::Path::new("hidden");
let snapshot = filesystem.classifier.snapshot_for(hidden);
let Err(error) = filesystem.resolve(&snapshot, hidden) else {
panic!("unproven host directory became visible");
};
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
filesystem
.record_created(Classification::Host, std::path::Path::new("changed"))
.unwrap();
fs::write(fixture.workspace.join("changed/external"), b"outside").unwrap();
let changed = std::path::Path::new("changed");
let snapshot = filesystem.classifier.snapshot_for(changed);
let Err(error) = filesystem.resolve(&snapshot, changed) else {
panic!("changed host directory retained creation provenance");
};
assert_eq!(error.raw_os_error(), Some(libc::ENOENT));
}
#[test]
#[ignore = "requires a usable host FUSE device"]
fn physical_router_preserves_lock_domains_across_transition() {

View file

@ -86,7 +86,7 @@ impl Provenance {
self.objects.extend(moved);
}
fn matches(&self, path: &Path, stamp: &ObjectStamp) -> bool {
pub(crate) fn matches(&self, path: &Path, stamp: &ObjectStamp) -> bool {
self.objects.get(path) == Some(stamp)
}
}

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs::File;
@ -40,17 +41,19 @@ use validation::{
};
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
pub const RUNSC_BUILD_ID: &str = "904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4";
pub const RUNSC_BUILD_ID: &str = "b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
pub const RUNSC_BUILD_ID_BYTES: [u8; 32] = [
144, 74, 179, 201, 189, 76, 153, 162, 113, 129, 7, 111, 44, 158, 162, 254, 204, 4, 29, 181,
167, 40, 201, 153, 84, 13, 102, 100, 23, 77, 44, 212,
185, 173, 111, 147, 168, 232, 144, 181, 147, 80, 97, 88, 43, 26, 18, 134, 130, 243, 253, 65,
200, 5, 60, 84, 208, 228, 79, 244, 93, 128, 153, 113,
];
pub const RUNSC_EXECUTABLE_NAME: &str =
"lazaret-runsc-904ab3c9bd4c99a27181076f2c9ea2fecc041db5a728c999540d6664174d2cd4";
"lazaret-runsc-b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
const OCI_VERSION: &str = "1.2.1";
const MAX_TERMINFO_BYTES: usize = 1_048_576;
const GVISOR_MOUNT_ANNOTATION_PREFIX: &str = "dev.gvisor.spec.mount.";
const OWNER_CHANGE_MOUNTS_ANNOTATION: &str = "dev.lazaret.runsc.owner-change-mounts";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SandboxBuildErrorKind {
@ -691,8 +694,10 @@ fn build_sandbox_bundle_from_source(
.readonly(false)
.build()
.map_err(oci_error)?;
let annotations = build_mount_annotations(request.configuration, &mounts)?;
let specification = SpecBuilder::default()
.version(OCI_VERSION)
.annotations(annotations)
.root(root)
.mounts(mounts)
.process(process)
@ -723,6 +728,54 @@ fn build_sandbox_bundle_from_source(
})
}
fn build_mount_annotations(
configuration: &ResolvedDomainConfiguration,
mounts: &[Mount],
) -> Result<HashMap<String, String>, SandboxBuildError> {
let owner_change_mounts = std::iter::once("/".to_owned())
.chain(
configuration
.fingerprint_record()
.persistent_dirs
.iter()
.cloned(),
)
.collect::<Vec<_>>();
let mut annotations = HashMap::from([(
OWNER_CHANGE_MOUNTS_ANNOTATION.to_owned(),
serde_json::to_string(&owner_change_mounts).map_err(|error| {
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
})?,
)]);
for (index, mount) in mounts.iter().enumerate() {
let destination = mount.destination().to_str().ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"mount destination is not UTF-8",
)
})?;
if mount.typ().as_deref() != Some("bind")
|| owner_change_mounts
.iter()
.any(|allowed| allowed == destination)
{
continue;
}
let Some(source) = mount.source().as_ref().and_then(|path| path.to_str()) else {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"bind mount source is not UTF-8",
));
};
let prefix = format!("{GVISOR_MOUNT_ANNOTATION_PREFIX}lazaret{index}.");
annotations.insert(format!("{prefix}source"), source.to_owned());
annotations.insert(format!("{prefix}type"), "bind".to_owned());
annotations.insert(format!("{prefix}share"), "shared".to_owned());
annotations.insert(format!("{prefix}directfs"), "off".to_owned());
}
Ok(annotations)
}
fn canonical_specification_json(specification: &Spec) -> Result<Vec<u8>, SandboxBuildError> {
let mut value = serde_json::to_value(specification).map_err(|error| {
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())

View file

@ -338,8 +338,15 @@ pub(super) fn build_process(
root_process: RootProcess,
_terminal: Option<&StagedHostTerminal>,
) -> Result<oci_spec::runtime::Process, SandboxBuildError> {
static IDENTITY_TRANSITION_CAPABILITIES: LazyLock<Capabilities> =
LazyLock::new(|| Capabilities::from([Capability::Setgid, Capability::Setuid]));
static IDENTITY_TRANSITION_CAPABILITIES: LazyLock<Capabilities> = LazyLock::new(|| {
Capabilities::from([
Capability::Chown,
Capability::DacOverride,
Capability::Fowner,
Capability::Setgid,
Capability::Setuid,
])
});
let empty = Capabilities::new();
let identity = IDENTITY_TRANSITION_CAPABILITIES.clone();
let capabilities = LinuxCapabilitiesBuilder::default()

View file

@ -303,14 +303,42 @@ fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape()
assert_eq!(
specification["process"]["capabilities"],
serde_json::json!({
"bounding": ["CAP_SETGID", "CAP_SETUID"],
"effective": ["CAP_SETGID", "CAP_SETUID"],
"bounding": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
"effective": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
"inheritable": [],
"permitted": ["CAP_SETGID", "CAP_SETUID"],
"permitted": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_SETGID", "CAP_SETUID"],
"ambient": []
})
);
let annotations = specification["annotations"].as_object().unwrap();
assert_eq!(
serde_json::from_str::<Vec<String>>(
annotations["dev.lazaret.runsc.owner-change-mounts"]
.as_str()
.unwrap()
)
.unwrap(),
["/", "/root", "/usr", "/var", "/etc", "/opt"]
);
let owner_change_mounts = BTreeSet::from(["/", "/root", "/usr", "/var", "/etc", "/opt"]);
for (index, mount) in specification["mounts"]
.as_array()
.unwrap()
.iter()
.enumerate()
{
let destination = mount["destination"].as_str().unwrap();
if mount["type"] != "bind" || owner_change_mounts.contains(destination) {
continue;
}
let prefix = format!("dev.gvisor.spec.mount.lazaret{index}.");
assert_eq!(annotations[&format!("{prefix}source")], mount["source"]);
assert_eq!(annotations[&format!("{prefix}type")], "bind");
assert_eq!(annotations[&format!("{prefix}share")], "shared");
assert_eq!(annotations[&format!("{prefix}directfs")], "off");
}
let namespaces = specification["linux"]["namespaces"].as_array().unwrap();
assert_eq!(
namespaces

View file

@ -395,7 +395,7 @@ pub(super) fn create_regular_file(parent: impl AsFd, name: &str) -> Result<(), S
parent.as_fd(),
name,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
Mode::from_raw_mode(0o644),
confined_resolve_flags(),
) {
Ok(descriptor) => File::from(descriptor).sync_all()?,
@ -440,7 +440,7 @@ pub(super) fn atomic_replace_at(
parent.as_fd(),
Path::new(&candidate),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
Mode::from_raw_mode(0o644),
confined_resolve_flags(),
) {
Ok(descriptor) => {
@ -514,6 +514,7 @@ pub(super) fn overlay_options(lower: &Path, upper: &Path, work: &Path) -> OsStri
append_fuse_option_path(&mut bytes, upper);
bytes.extend_from_slice(b",workdir=");
append_fuse_option_path(&mut bytes, work);
bytes.extend_from_slice(b",xattr_permissions=2,noacl");
OsString::from_vec(bytes)
}

View file

@ -67,6 +67,7 @@ fn run() -> io::Result<()> {
}
Err(error) => return Err(error),
}
enter_runtime_user_namespace()?;
let tree = TestTree::new().map_err(|error| io::Error::other(format!("test tree: {error}")))?;
let configuration = configuration(&tree)
.map_err(|error| io::Error::other(format!("configuration: {error}")))?;
@ -251,6 +252,25 @@ fn run() -> io::Result<()> {
Ok(())
}
#[allow(deprecated)]
fn enter_runtime_user_namespace() -> io::Result<()> {
let uid = rustix::process::getuid().as_raw();
let gid = rustix::process::getgid().as_raw();
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWUSER)
.map_err(|error| io::Error::other(format!("fixture user namespace: {error}")))?;
fs::write("/proc/self/setgroups", "deny")?;
fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWNS)
.map_err(|error| io::Error::other(format!("fixture mount namespace: {error}")))?;
rustix::mount::mount_change(
"/",
rustix::mount::MountPropagationFlags::PRIVATE | rustix::mount::MountPropagationFlags::REC,
)
.map_err(|error| io::Error::other(format!("fixture private propagation: {error}")))?;
Ok(())
}
#[allow(deprecated)]
fn run_private_recovery_holder(arguments: &[OsString]) -> io::Result<()> {
if arguments.len() != 2 {