diff --git a/Cargo.lock b/Cargo.lock index c09a789..9018389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,6 +763,7 @@ dependencies = [ "async-trait", "clap", "flate2", + "libc", "serde", "serde_json", "sha2", diff --git a/crates/tinybus/Cargo.toml b/crates/tinybus/Cargo.toml index 6216307..affda57 100644 --- a/crates/tinybus/Cargo.toml +++ b/crates/tinybus/Cargo.toml @@ -41,6 +41,11 @@ zip = { version = "2", optional = true, default-features = false, features = ["d tempfile = { version = "3", optional = true } toml = { version = "1", optional = true } +# Unix only, for the module directory gate's passwd/group lookups +# (`getpwuid_r`, `getgrgid_r`), whose struct layouts differ per platform. +[target.'cfg(unix)'.dependencies] +libc = { version = "0.2", optional = true } + [features] # `uds` and `macros` are on by default because a bus you cannot connect to over # a socket, and an interface you have to hand-dispatch, are not what anyone is @@ -69,7 +74,7 @@ cli = [ # Load trusted, in-process cdylib modules. Off by default because a loaded # module shares the host's address space and full privileges. -modules = ["dep:ureq", "dep:flate2", "dep:tar", "dep:zip", "dep:tempfile", "dep:toml"] +modules = ["dep:ureq", "dep:flate2", "dep:tar", "dep:zip", "dep:tempfile", "dep:toml", "dep:libc"] [dev-dependencies] diff --git a/crates/tinybus/src/error.rs b/crates/tinybus/src/error.rs index 06a7666..d176b7d 100644 --- a/crates/tinybus/src/error.rs +++ b/crates/tinybus/src/error.rs @@ -210,8 +210,10 @@ pub enum Error { /// A dynamic module failed a fixed admission rule. /// - /// `file` is a basename only and `reason` is selected by the host. Neither - /// field may contain a path or attacker-controlled descriptor bytes. + /// `file` is a basename only and `reason` is selected by the host; a unix + /// directory refusal ends by naming the one ancestor component that + /// failed. Neither field may contain a full path or attacker-controlled + /// descriptor bytes. #[error("module `{file}` refused: {reason}")] ModuleRefused { /// Sanitized artifact basename. diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 4f0e51c..8cb107e 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1841,7 +1841,10 @@ const PLACEMENT_REFUSALS: &[&str] = &[ /// Whether `error` refused an artifact for its location rather than its content. pub(crate) fn is_placement_refusal(error: &Error) -> bool { - matches!(error, Error::ModuleRefused { reason, .. } if PLACEMENT_REFUSALS.contains(&reason.as_str())) + // A unix refusal goes on to name the ancestor that failed, so match the + // phrase it starts with. + matches!(error, Error::ModuleRefused { reason, .. } + if PLACEMENT_REFUSALS.iter().any(|phrase| reason.starts_with(phrase))) } fn check_allowlist(path: &Path, file: std::fs::File) -> Result<()> { @@ -1914,10 +1917,6 @@ fn has_library_extension(path: &Path) -> bool { fn check_directory(path: &Path) -> Result<()> { use std::os::unix::fs::MetadataExt; - unsafe extern "C" { - fn getuid() -> u32; - } - let absolute = if path.is_absolute() { path.to_path_buf() } else { @@ -1925,7 +1924,11 @@ fn check_directory(path: &Path) -> Result<()> { .map_err(|_| Error::module_refused(path, "module directory is unavailable"))? .join(path) }; - let uid = unsafe { getuid() }; + // SAFETY: getuid cannot fail and touches no memory. + let uid = unsafe { libc::getuid() }; + // Looked up once per walk, and only if some ancestor is group-writable: + // a directory-service (LDAP, sssd) lookup is not free. + let private_gid = std::cell::OnceCell::new(); for component in absolute.ancestors() { let metadata = std::fs::symlink_metadata(component) .map_err(|_| Error::module_refused(path, "module directory is unavailable"))?; @@ -1935,36 +1938,348 @@ fn check_directory(path: &Path) -> Result<()> { "module search path contains a non-directory component", )); } + let mode = metadata.mode(); + // Only a directory that is group-writable, not sticky, and owned by the + // user or root can reach the private-group exception, so everything + // else (`/tmp`) skips the account lookup. + let needs_private_gid = mode & 0o020 != 0 + && mode & 0o1000 == 0 + && (metadata.uid() == uid || metadata.uid() == 0) + && !root_equivalent_group(metadata.uid(), metadata.gid()); + let private_gid = if needs_private_gid { + // An ACL can grant another account what the mode does not show. + (!has_extended_acl(component)) + .then(|| *private_gid.get_or_init(|| current_user_private_gid(uid))) + .flatten() + } else { + None + }; if let Some(reason) = - unix_directory_refusal(metadata.uid(), metadata.gid(), metadata.mode(), uid) + unix_directory_refusal(metadata.uid(), metadata.gid(), mode, uid, private_gid) { - return Err(Error::module_refused(path, reason)); + let mode = mode & 0o7777; + let label = ancestor_label(component, &absolute); + tracing::debug!( + directory = %label, + mode = format_args!("{mode:04o}"), + reason, + "module directory ancestor refused" + ); + return Err(Error::module_refused( + path, + format!("{reason} at {label} mode {mode:04o}"), + )); } } Ok(()) } +/// Top-level system directories that are safe to report; matched only +/// directly beneath the filesystem root, never by basename elsewhere. +#[cfg(unix)] +const REPORTABLE_ROOT_CHILDREN: &[&str] = &[ + "bin", + "boot", + "etc", + "home", + "lib", + "lib64", + "Library", + "media", + "mnt", + "opt", + "private", + "root", + "run", + "sbin", + "srv", + "tmp", + "usr", + "var", + "Users", + "Applications", + "Volumes", +]; + +/// How a refusal names the ancestor that failed. +/// +/// A refusal reaches telemetry, so it never carries a name that could belong +/// to an account or a user-made directory. Only the filesystem root, the +/// module directory itself, `$HOME`, and a fixed top-level system directory +/// are named; every other component is reported as `an ancestor directory`. +#[cfg(unix)] +fn ancestor_label(component: &Path, module_directory: &Path) -> String { + if component.parent().is_none() { + return "the filesystem root".to_string(); + } + if component == module_directory { + return "the directory itself".to_string(); + } + if std::env::var_os("HOME").is_some_and(|home| Path::new(&home) == component) { + return "the home directory".to_string(); + } + let top_level = component.parent() == Some(Path::new("/")); + match component.file_name().and_then(|name| name.to_str()) { + Some(name) if top_level && REPORTABLE_ROOT_CHILDREN.contains(&name) => name.to_string(), + _ => "an ancestor directory".to_string(), + } +} + +/// `private_gid` is the current user's private group (see +/// [`is_user_private_group`]), or `None` when they have none. #[cfg(unix)] fn unix_directory_refusal( owner: u32, group: u32, mode: u32, current_uid: u32, + private_gid: Option, ) -> Option<&'static str> { let world_writable = mode & 0o002 != 0; let group_writable = mode & 0o020 != 0; let sticky = mode & 0o1000 != 0; + // Only consulted for a directory this user or root owns, where group + // write by the user's private group grants nobody but that user. + let group_grants_only_the_user = + root_equivalent_group(owner, group) || private_gid == Some(group); if owner != current_uid && owner != 0 { Some("module directory is owned by another user") } else if sticky { None - } else if world_writable || (group_writable && !root_equivalent_group(owner, group)) { + } else if world_writable || (group_writable && !group_grants_only_the_user) { Some("module directory is writable by another user") } else { None } } +/// Whether `etc/nsswitch.conf` text resolves users and groups from local +/// files only (`files`, `systemd`; `compat` can import NIS entries, so it is not local), the one case where `/etc/passwd` +/// and `/etc/group` are the whole account database. +#[cfg(unix)] +fn accounts_are_local(nsswitch: &str) -> bool { + let mut seen = [false; 4]; + for line in nsswitch.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + let Some((database, sources)) = line.split_once(':') else { + continue; + }; + let index = match database.trim() { + "passwd" => 0, + "group" => 1, + // Supplementary memberships can come from here as well; when it + // is stated it must be local too, and an absent line follows + // `group`. + "initgroups" => 2, + // The subordinate id provider must be local too (`/etc/subgid`). + "subid" => 3, + _ => continue, + }; + seen[index] = true; + // `[NOTFOUND=return]` style actions are not sources. + let local = sources + .split_whitespace() + .filter(|source| !source.starts_with('[')) + .all(|source| matches!(source, "files" | "systemd")); + if !local { + return false; + } + } + seen[0] && seen[1] +} + +/// Whether `gid` falls inside any subordinate-gid range of `/etc/subgid` +/// (`name:start:count`). A user namespace can map such a gid for another +/// account, so it is not private. Unreadable records count as covering. +#[cfg(unix)] +fn gid_is_delegated(subgid: &str, gid: u32) -> bool { + subgid.lines().any(|line| { + if line.trim().is_empty() || line.starts_with('#') { + return false; + } + let fields: Vec<&str> = line.split(':').collect(); + match ( + fields.get(1).and_then(|start| start.parse::().ok()), + fields.get(2).and_then(|count| count.parse::().ok()), + ) { + (Some(start), Some(count)) if fields.len() == 3 => { + (start..start.saturating_add(count)).contains(&u64::from(gid)) + } + _ => true, + } + }) +} + +/// The gid of `uid`'s private group, from the text of `/etc/passwd` and +/// `/etc/group`, or `None`. +/// +/// Debian, Ubuntu and Fedora give every account a group of its own, named +/// after it, as its primary group, and pair that with umask 002, so `$HOME` +/// and `~/.cache` are routinely `user:user 0775`. Group write there grants +/// nobody but the user, and refusing it refused the per-user release cache +/// for good. The rule is deliberately narrow: the group must be the user's +/// primary group, carry the user's own name, list no member, and be the +/// primary group of no other account. Anything unproven is shared. +#[cfg(unix)] +fn private_group_in(passwd: &str, group: &str, uid: u32) -> Option { + let mut user = None; + let mut others_with_gid = Vec::new(); + let mut names = std::collections::HashSet::new(); + for line in passwd.lines() { + let fields: Vec<&str> = line.split(':').collect(); + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + // A record that cannot be read could be the account that shares the + // gid, so any malformed line makes the whole answer unavailable. + if fields.len() < 4 { + return None; + } + let (Ok(entry_uid), Ok(entry_gid)) = (fields[2].parse::(), fields[3].parse::()) + else { + return None; + }; + // A second record under the same name would resolve to this group's + // member entry as well. + if !names.insert(fields[0]) { + return None; + } + if entry_uid == uid { + if user.is_some() { + return None; + } + user = Some((fields[0], entry_gid)); + } else { + others_with_gid.push(entry_gid); + } + } + let (user_name, user_gid) = user?; + if user_name.is_empty() || others_with_gid.contains(&user_gid) { + return None; + } + let mut records = Vec::new(); + for line in group.lines() { + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split(':').collect(); + // As for passwd: a record that cannot be read could share the gid. + if fields.len() < 4 || fields[2].parse::().is_err() { + return None; + } + records.push(fields); + } + let mut matches = records + .into_iter() + .filter(|fields| fields[2].parse::().ok() == Some(user_gid)); + let entry = matches.next()?; + if matches.next().is_some() || entry[0] != user_name { + return None; + } + // Supplementary members can write through the group too. + let members_ok = entry[3] + .split(',') + .filter(|member| !member.is_empty()) + .all(|member| member == user_name); + members_ok.then_some(user_gid) +} + +/// Where nss-systemd reads user and group records from. +#[cfg(unix)] +const SYSTEMD_USER_RECORD_DIRECTORIES: &[&str] = &[ + "/etc/userdb", + "/run/userdb", + "/run/systemd/userdb", + "/run/host/userdb", + "/usr/lib/userdb", + "/var/lib/systemd/home", + "/run/systemd/home", +]; + +/// A missing directory has no entries; one that cannot be read is assumed to. +#[cfg(unix)] +fn directory_has_no_entries(path: &Path) -> bool { + match std::fs::read_dir(path) { + Ok(mut entries) => entries.next().is_none(), + Err(error) => error.kind() == std::io::ErrorKind::NotFound, + } +} + +/// The current user's private group. Reads only the local account files and +/// only when `nsswitch.conf` shows no directory service, so no NSS call is +/// made, nothing blocks on the network, and no process-wide libc cursor is +/// shared. Any doubt answers `None`, which keeps group write refused. +#[cfg(unix)] +fn current_user_private_gid(uid: u32) -> Option { + let read = |path| std::fs::read_to_string(path).ok(); + let nsswitch = read("/etc/nsswitch.conf")?; + if !accounts_are_local(&nsswitch) { + return None; + } + let mut gid = private_group_in(&read("/etc/passwd")?, &read("/etc/group")?, uid); + // A gid delegated through /etc/subgid can be mapped by another account. + if let Some(candidate) = gid { + match std::fs::read_to_string("/etc/subgid") { + Ok(subgid) if gid_is_delegated(&subgid, candidate) => gid = None, + Err(error) if error.kind() != std::io::ErrorKind::NotFound => gid = None, + _ => {} + } + } + // `nss-systemd` serves dynamic users, whose gids are in reserved ranges, + // and user records (`memberOf` can add supplementary groups) from the + // userdb and homed directories. Trust it only for a regular-user gid with + // no such records on disk. + if nsswitch.contains("systemd") + && !(gid.is_some_and(|gid| (1_000..60_000).contains(&gid)) + && SYSTEMD_USER_RECORD_DIRECTORIES + .iter() + .all(|directory| directory_has_no_entries(Path::new(directory)))) + { + gid = None; + } + tracing::debug!( + uid, + private = gid.is_some(), + "module directory gate checked the user's primary group" + ); + gid +} + +/// Whether a directory carries a POSIX ACL beyond its mode bits, which could +/// grant another account write access the mode and group do not show. An +/// unreadable answer counts as an ACL. +#[cfg(target_os = "linux")] +fn has_extended_acl(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return true; + }; + // SAFETY: both pointers are valid for the call; a null buffer with size 0 + // only asks for the attribute's length. + let length = unsafe { + libc::getxattr( + path.as_ptr(), + c"system.posix_acl_access".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + if length >= 0 { + return true; + } + !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ENODATA) | Some(libc::ENOTSUP) + ) +} + +/// Other unixes: without a probe for ACLs, the private-group exception is off. +#[cfg(all(unix, not(target_os = "linux")))] +fn has_extended_acl(_path: &Path) -> bool { + true +} + /// Whether group write on a root-owned directory grants nothing beyond root. /// /// macOS only. It ships `/Applications` as `root:admin 0775`, and refusing it diff --git a/crates/tinybus/src/module/host_admission_tests.rs b/crates/tinybus/src/module/host_admission_tests.rs index 4c92c27..06da4cf 100644 --- a/crates/tinybus/src/module/host_admission_tests.rs +++ b/crates/tinybus/src/module/host_admission_tests.rs @@ -142,6 +142,28 @@ fn a_private_directory_this_user_created_is_admitted() { check_directory(&nested).unwrap(); } +/// Ubuntu's umask 002 leaves `~/.cache` as `user:user 0775`. Where this +/// account has a private group, a directory like that passes the gate; where +/// it has none (CI runners, macOS `staff`) there is nothing to show. +#[cfg(unix)] +#[test] +fn a_directory_writable_by_the_users_private_group_is_admitted() { + use std::os::unix::fs::PermissionsExt; + + let uid = unsafe { libc::getuid() }; + let Some(private_gid) = current_user_private_gid(uid) else { + return; + }; + let directory = tempfile::tempdir().unwrap(); + let root = real(&directory); + let cache = root.join("cache"); + let nested = cache.join("clock").join("0.1.0"); + std::fs::create_dir_all(&nested).unwrap(); + std::os::unix::fs::chown(&cache, None, Some(private_gid)).unwrap(); + std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o775)).unwrap(); + check_directory(&nested).unwrap(); +} + /// `/Applications` ships as `root:admin 0775`. Refusing it refused every module /// bundled in a normally installed app. #[cfg(target_os = "macos")] diff --git a/crates/tinybus/src/module/host_tests.rs b/crates/tinybus/src/module/host_tests.rs index 01bd36d..5c3d03d 100644 --- a/crates/tinybus/src/module/host_tests.rs +++ b/crates/tinybus/src/module/host_tests.rs @@ -1377,7 +1377,8 @@ fn a_world_writable_module_directory_is_refused_before_any_dlopen() { metadata.uid(), metadata.gid(), metadata.permissions().mode(), - metadata.uid() + metadata.uid(), + None ), Some("module directory is writable by another user") ); @@ -1484,25 +1485,28 @@ fn a_module_file_writable_by_everyone_is_refused() { #[cfg(unix)] #[test] fn a_sticky_world_writable_module_directory_is_accepted() { - assert_eq!(unix_directory_refusal(0, 0, 0o1777, 1_000), None); - assert_eq!(unix_directory_refusal(1_000, 1_000, 0o1777, 1_000), None); + assert_eq!(unix_directory_refusal(0, 0, 0o1777, 1_000, None), None); + assert_eq!( + unix_directory_refusal(1_000, 1_000, 0o1777, 1_000, None), + None + ); } #[cfg(unix)] #[test] fn a_module_directory_owned_by_another_user_is_refused() { assert_eq!( - unix_directory_refusal(1_001, 1_001, 0o755, 1_000), + unix_directory_refusal(1_001, 1_001, 0o755, 1_000, None), Some("module directory is owned by another user") ); - assert_eq!(unix_directory_refusal(0, 0, 0o755, 1_000), None); + assert_eq!(unix_directory_refusal(0, 0, 0o755, 1_000, None), None); } #[cfg(target_os = "macos")] #[test] fn a_root_owned_directory_writable_by_wheel_is_accepted_on_macos() { // `wheel` holds only root on macOS. - assert_eq!(unix_directory_refusal(0, 0, 0o775, 501), None); + assert_eq!(unix_directory_refusal(0, 0, 0o775, 501, None), None); } #[cfg(all(unix, not(target_os = "macos")))] @@ -1510,7 +1514,7 @@ fn a_root_owned_directory_writable_by_wheel_is_accepted_on_macos() { fn group_write_by_gid_zero_is_refused_off_macos() { // gid 0 is not guaranteed to be root-only outside macOS. assert_eq!( - unix_directory_refusal(0, 0, 0o775, 1_000), + unix_directory_refusal(0, 0, 0o775, 1_000, None), Some("module directory is writable by another user") ); } @@ -1519,7 +1523,7 @@ fn group_write_by_gid_zero_is_refused_off_macos() { #[test] fn the_macos_applications_directory_is_accepted() { // /Applications ships as `root:admin 0775`. - assert_eq!(unix_directory_refusal(0, 80, 0o40775, 501), None); + assert_eq!(unix_directory_refusal(0, 80, 0o40775, 501, None), None); } #[cfg(unix)] @@ -1527,19 +1531,250 @@ fn the_macos_applications_directory_is_accepted() { fn group_write_by_an_ordinary_group_is_still_refused() { let refused = Some("module directory is writable by another user"); // A root-owned directory writable by a non-root group. - assert_eq!(unix_directory_refusal(0, 20, 0o775, 501), refused); + assert_eq!(unix_directory_refusal(0, 20, 0o775, 501, None), refused); // The user's own directory writable by a group: the gid alone cannot // show the group is private to them. - assert_eq!(unix_directory_refusal(1_000, 1_000, 0o775, 1_000), refused); + assert_eq!( + unix_directory_refusal(1_000, 1_000, 0o775, 1_000, None), + refused + ); #[cfg(not(target_os = "macos"))] - assert_eq!(unix_directory_refusal(0, 80, 0o775, 1_000), refused); + assert_eq!(unix_directory_refusal(0, 80, 0o775, 1_000, None), refused); +} + +/// Ubuntu's user-private-group scheme (umask 002) leaves `$HOME` and +/// `~/.cache` as `user:user 0775`. Group write there grants nobody but the +/// user, so the per-user release cache beneath them must stay admissible. +#[cfg(unix)] +#[test] +fn group_write_by_the_users_private_group_is_admitted() { + // The user's own directory, group-writable by their private group. + assert_eq!( + unix_directory_refusal(1_000, 1_000, 0o775, 1_000, Some(1_000)), + None + ); + assert_eq!( + unix_directory_refusal(1_000, 1_000, 0o40770, 1_000, Some(1_000)), + None + ); + // A root-owned directory whose group is the user's private group. + assert_eq!( + unix_directory_refusal(0, 1_000, 0o775, 1_000, Some(1_000)), + None + ); +} + +#[cfg(unix)] +#[test] +fn group_write_by_a_shared_group_is_refused_even_with_a_private_group() { + let refused = Some("module directory is writable by another user"); + // `users` (gid 100) is shared, whoever owns the directory. + assert_eq!( + unix_directory_refusal(1_000, 100, 0o775, 1_000, Some(1_000)), + refused + ); + assert_eq!( + unix_directory_refusal(0, 100, 0o775, 1_000, Some(1_000)), + refused + ); + // Another user's private group is not this user's. + assert_eq!( + unix_directory_refusal(1_000, 1_001, 0o775, 1_000, Some(1_000)), + refused + ); + // A private group never excuses world write. + assert_eq!( + unix_directory_refusal(1_000, 1_000, 0o777, 1_000, Some(1_000)), + refused + ); + // Nor ownership by another account. + assert_eq!( + unix_directory_refusal(1_001, 1_000, 0o775, 1_000, Some(1_000)), + Some("module directory is owned by another user") + ); +} + +#[cfg(unix)] +#[test] +fn the_user_private_group_rule_reads_local_accounts_and_proves_exclusivity() { + let passwd = "root:x:0:0::/root:/bin/sh\nalice:x:1000:1000::/home/alice:/bin/sh\n"; + let group = "root:x:0:\nalice:x:1000:\n"; + assert_eq!(private_group_in(passwd, group, 1_000), Some(1_000)); + // The user listed in their own group is still private. + assert_eq!( + private_group_in(passwd, "alice:x:1000:alice\n", 1_000), + Some(1_000) + ); + // Another member can write through the group. + assert_eq!( + private_group_in(passwd, "alice:x:1000:alice,bob\n", 1_000), + None + ); + // Another account with this primary gid, absent from the member list. + let shared = format!("{passwd}bob:x:1001:1000::/home/bob:/bin/sh\n"); + assert_eq!(private_group_in(&shared, group, 1_000), None); + // A primary group not named after the user is shared (`users`). + let users = "alice:x:1000:100::/home/alice:/bin/sh\n"; + assert_eq!(private_group_in(users, "users:x:100:\n", 1_000), None); + // Two accounts under one name are ambiguous. + let twin = format!("{passwd}alice:x:1001:1001::/home/twin:/bin/sh\n"); + assert_eq!(private_group_in(&twin, group, 1_000), None); + // A malformed record could be the account that shares the gid. + assert_eq!( + private_group_in(&format!("{passwd}broken-line\n"), group, 1_000), + None + ); + // A malformed group record could share the gid. + assert_eq!( + private_group_in(passwd, &format!("{group}other:x:1000\n"), 1_000), + None + ); + // Unknown account, missing group, or duplicate group entries prove nothing. + assert_eq!(private_group_in(passwd, group, 4_242), None); + assert_eq!(private_group_in(passwd, "root:x:0:\n", 1_000), None); + assert_eq!( + private_group_in(passwd, "alice:x:1000:\nother:x:1000:\n", 1_000), + None + ); +} + +#[cfg(unix)] +#[test] +fn a_gid_inside_a_subordinate_range_is_delegated() { + let subgid = "alice:100000:65536\n# c\n"; + assert!(gid_is_delegated(subgid, 100_000)); + assert!(gid_is_delegated(subgid, 165_535)); + assert!(!gid_is_delegated(subgid, 165_536)); + assert!(!gid_is_delegated(subgid, 1_000)); + assert!(!gid_is_delegated("", 1_000)); + // An unreadable record is assumed to cover the gid. + assert!(gid_is_delegated("garbage\n", 1_000)); +} + +#[cfg(unix)] +#[test] +fn accounts_count_as_local_only_without_a_directory_service() { + assert!(accounts_are_local( + "passwd: files systemd\ngroup: files systemd\nhosts: dns\n" + )); + assert!(accounts_are_local("# c\npasswd: files\ngroup: files # x\n")); + // `compat` can import NIS entries through `+` lines. + assert!(!accounts_are_local("passwd: compat\ngroup: compat\n")); + assert!(!accounts_are_local("passwd: files sss\ngroup: files\n")); + assert!(!accounts_are_local("passwd: files ldap\ngroup: files\n")); + // Supplementary memberships may come from `initgroups`. + assert!(!accounts_are_local( + "passwd: files\ngroup: files\ninitgroups: files sss\n" + )); + assert!(accounts_are_local( + "passwd: files\ngroup: files\ninitgroups: files\n" + )); + assert!(!accounts_are_local( + "passwd: files\ngroup: files\nsubid: sss\n" + )); + // Both databases must be stated. + assert!(!accounts_are_local("passwd: files\n")); + assert!(!accounts_are_local("")); +} + +#[cfg(unix)] +#[test] +fn a_directory_counts_as_empty_only_when_missing_or_without_entries() { + let directory = tempfile::tempdir().unwrap(); + assert!(directory_has_no_entries(directory.path())); + assert!(directory_has_no_entries(&directory.path().join("missing"))); + std::fs::write(directory.path().join("alice.user"), "{}").unwrap(); + assert!(!directory_has_no_entries(directory.path())); + // Not a directory: unreadable, so not proven empty. + assert!(!directory_has_no_entries( + &directory.path().join("alice.user") + )); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_plain_directory_has_no_extended_acl() { + let directory = tempfile::tempdir().unwrap(); + // A parent with a default ACL hands it to the new directory; only a + // directory without one is a fair subject. + let parent_default_acl = directory.path().parent().is_some_and(|parent| { + let parent = std::ffi::CString::new(parent.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: valid NUL-terminated strings; null buffer asks for the length. + unsafe { + libc::getxattr( + parent.as_ptr(), + c"system.posix_acl_default".as_ptr(), + std::ptr::null_mut(), + 0, + ) >= 0 + } + }); + if !parent_default_acl { + assert!( + !has_extended_acl(directory.path()), + "a plain directory must not be reported as ACL-bearing" + ); + } + assert!(has_extended_acl(&directory.path().join("missing"))); +} + +/// A refusal names the ancestor that failed: walking to `/` means the culprit +/// is rarely the module directory itself, and the bare phrase left users +/// guessing which of a dozen directories to fix. +#[cfg(unix)] +#[test] +fn a_directory_refusal_names_the_ancestor_that_failed() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(directory.path()).unwrap(); + let shared = root.join("shared-parent"); + let module_dir = shared.join("ubuntu-22.04-x86_64"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::set_permissions(&module_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap(); + + let refusal = check_directory(&module_dir).unwrap_err(); + let Error::ModuleRefused { file, reason } = &refusal else { + panic!("unexpected error {refusal}"); + }; + assert_eq!(file, "ubuntu-22.04-x86_64"); + assert!( + reason.starts_with("module directory is writable by another user"), + "{reason}" + ); + // The failing component is not a fixed system name, so it is not echoed. + assert!(reason.contains("an ancestor directory"), "{reason}"); + assert!(!reason.contains("shared-parent"), "{reason}"); + assert!(is_placement_refusal(&refusal), "{refusal}"); +} + +#[cfg(unix)] +#[test] +fn a_refused_ancestor_is_named_without_leaking_a_path() { + let module = Path::new("/opt/app/bundled-modules/x86_64"); + let label = |ancestor: &str| ancestor_label(Path::new(ancestor), module); + assert_eq!(label("/"), "the filesystem root"); + assert_eq!( + label("/opt/app/bundled-modules/x86_64"), + "the directory itself" + ); + assert_eq!(label("/opt"), "opt"); + assert_eq!(label("/opt/app"), "an ancestor directory"); + // Names are trusted only directly beneath the root. + assert_eq!(label("/srv/secret/.cache"), "an ancestor directory"); + assert_eq!(label("/tmp/private"), "an ancestor directory"); + assert_eq!(label("/home/someone/.cache"), "an ancestor directory"); + assert_eq!(label("/home/.alice"), "an ancestor directory"); + assert_eq!(label("/home/someone-else"), "an ancestor directory"); + assert_eq!(label("/export/home/bob"), "an ancestor directory"); } #[cfg(unix)] #[test] fn world_write_without_sticky_is_refused_even_for_root_groups() { assert_eq!( - unix_directory_refusal(0, 0, 0o777, 1_000), + unix_directory_refusal(0, 0, 0o777, 1_000, None), Some("module directory is writable by another user") ); } diff --git a/docs/modules/module/README.md b/docs/modules/module/README.md index e7bebb3..16bf09f 100644 --- a/docs/modules/module/README.md +++ b/docs/modules/module/README.md @@ -27,7 +27,12 @@ restart. 1. Check every directory component's ownership/mode, require a regular platform library file no larger than 512 MiB, and enforce `modules.toml` - when present. + when present. On Unix a component must be owned by the user or root and + not writable by anyone else: world write is refused unless the sticky bit + is set, and group write is refused unless the group grants nobody else + (the user's private group, on Linux only, or `wheel`/`admin` + on a root-owned macOS directory). A private group is the user's primary + group, named after the user, with no other member and no other account using it as its primary group. It is established only from `/etc/passwd` and `/etc/group` on a host whose `nsswitch.conf` names no directory service (`files`, or `files systemd` with no systemd user records), and never for a directory with a POSIX ACL; otherwise group write stays refused. 2. Read an adjacent lazy manifest when one is present; otherwise load eagerly and locally (`RTLD_NOW | RTLD_LOCAL` on Unix). On Windows, search the module's directory first for its dependencies, then System32. An artifact @@ -190,7 +195,11 @@ reported, not replaced by a download. Refusing one artifact does not prevent the host from admitting other artifacts in the same directory. The refused artifact's error contains only a sanitized -basename and fixed reason. +basename and fixed reason; a Unix directory refusal ends by naming the one +ancestor and its mode (`... at opt mode 0777`). Only a fixed system or XDG +location (`/usr`, `.cache`) is named; the filesystem root, the module directory and +the home directory are named as such, and any other component is reported as +`an ancestor directory`, so no account name or path reaches the error. Lifecycle states are `discovered`, `rejected`, `unresolved`, `resolved`, `initializing`, `ready`, `serving`, `faulted`, `failed`, `stopped`, and