use std::collections::HashSet;
use std::ffi::{CStr, CString, c_int, c_uint};
use std::fs::{DirBuilder, File, Metadata, OpenOptions};
use std::io::{self, Error, ErrorKind};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
use std::os::unix::{
ffi::OsStrExt,
fs::{DirBuilderExt, MetadataExt, PermissionsExt},
prelude::OpenOptionsExt,
};
use std::path::{Component, Path};
use super::{
Group, GroupId, User, UserId, cerr, inject_group, interface::UnixUser, set_supplementary_groups,
};
use crate::common::resolve::CurrentUser;
#[cfg(target_os = "linux")]
pub(crate) fn no_new_privs_enabled() -> io::Result<bool> {
let no_new_privs =
crate::cutils::cerr(unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) })?;
Ok(no_new_privs != 0)
}
pub(crate) fn sudo_call<T>(
target_user: &User,
target_group: &Group,
operation: impl FnOnce() -> T,
) -> io::Result<T> {
const KEEP_UID: libc::uid_t = -1i32 as libc::uid_t;
const KEEP_GID: libc::gid_t = -1i32 as libc::gid_t;
let (cur_user_id, cur_group_id) =
unsafe { (UserId::new(libc::geteuid()), GroupId::new(libc::getegid())) };
let cur_groups = {
let len = cerr(unsafe { libc::getgroups(0, std::ptr::null_mut()) })?;
let mut buf: Vec<GroupId> = vec![GroupId::new(KEEP_GID); len as usize];
cerr(unsafe {
libc::getgroups(len, buf.as_mut_ptr().cast::<libc::gid_t>())
})?;
buf
};
let mut target_groups = target_user.groups.clone();
inject_group(target_group.gid, &mut target_groups);
if cfg!(test)
&& target_user.uid == cur_user_id
&& target_group.gid == cur_group_id
&& target_groups.iter().collect::<HashSet<_>>() == cur_groups.iter().collect::<HashSet<_>>()
{
return Ok(operation());
}
struct ResetUserGuard(UserId, GroupId, Vec<GroupId>);
impl Drop for ResetUserGuard {
fn drop(&mut self) {
(|| {
cerr(unsafe { libc::setresuid(KEEP_UID, UserId::inner(&self.0), KEEP_UID) })?;
cerr(unsafe { libc::setresgid(KEEP_GID, GroupId::inner(&self.1), KEEP_GID) })?;
set_supplementary_groups(&self.2)
})()
.expect("could not restore to saved user id");
}
}
let guard = ResetUserGuard(cur_user_id, cur_group_id, cur_groups);
set_supplementary_groups(&target_groups)?;
cerr(unsafe { libc::setresgid(KEEP_GID, GroupId::inner(&target_group.gid), KEEP_GID) })?;
cerr(unsafe { libc::setresuid(KEEP_UID, UserId::inner(&target_user.uid), KEEP_UID) })?;
let result = operation();
std::mem::drop(guard);
Ok(result)
}
enum Op {
Read = 4,
Write = 2,
Exec = 1,
}
enum Category {
Owner = 2,
Group = 1,
World = 0,
}
fn mode(who: Category, what: Op) -> u32 {
(what as u32) << (3 * who as u32)
}
pub fn secure_open_sudoers(path: impl AsRef<Path>, check_parent_dir: bool) -> io::Result<File> {
let mut open_options = OpenOptions::new();
open_options.read(true);
secure_open_impl(path.as_ref(), &mut open_options, check_parent_dir, false)
}
pub fn secure_open_cookie_file(path: impl AsRef<Path>) -> io::Result<File> {
let mut open_options = OpenOptions::new();
open_options
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(mode(Category::Owner, Op::Write) | mode(Category::Owner, Op::Read));
secure_open_impl(path.as_ref(), &mut open_options, true, true)
}
pub fn zoneinfo_path() -> Option<&'static str> {
let paths = [
"/usr/share/zoneinfo",
"/usr/share/lib/zoneinfo",
"/usr/lib/zoneinfo",
];
paths.into_iter().find(|p| {
let path = Path::new(p);
path.metadata().and_then(|meta| checks(path, meta)).is_ok()
})
}
fn checks(path: &Path, meta: Metadata) -> io::Result<()> {
let error = |msg| Error::new(ErrorKind::PermissionDenied, msg);
let path_mode = meta.permissions().mode();
if meta.uid() != 0 {
Err(error(xlat!(
"{path} must be owned by root",
path = path.display()
)))
} else if meta.gid() != 0 && (path_mode & mode(Category::Group, Op::Write) != 0) {
Err(error(xlat!(
"{path} cannot be group-writable",
path = path.display()
)))
} else if path_mode & mode(Category::World, Op::Write) != 0 {
Err(error(xlat!(
"{path} cannot be world-writable",
path = path.display()
)))
} else {
Ok(())
}
}
fn secure_open_impl(
path: &Path,
open_options: &mut OpenOptions,
check_parent_dir: bool,
create_parent_dirs: bool,
) -> io::Result<File> {
let error = |msg| Error::new(ErrorKind::PermissionDenied, msg);
if check_parent_dir || create_parent_dirs {
if let Some(parent_dir) = path.parent() {
if create_parent_dirs && !parent_dir.exists() {
DirBuilder::new()
.recursive(true)
.mode(
mode(Category::Owner, Op::Write)
| mode(Category::Owner, Op::Read)
| mode(Category::Owner, Op::Exec)
| mode(Category::Group, Op::Exec)
| mode(Category::World, Op::Exec),
)
.create(parent_dir)?;
}
if check_parent_dir {
let parent_meta = std::fs::metadata(parent_dir)?;
checks(parent_dir, parent_meta)?;
}
} else {
return Err(error(xlat!(
"{path} has no valid parent directory",
path = path.display()
)));
}
}
let file = open_options.open(path)?;
let meta = file.metadata()?;
checks(path, meta)?;
Ok(file)
}
fn open_at(parent: BorrowedFd, file_name: &CStr, create: bool) -> io::Result<OwnedFd> {
let flags = if create {
libc::O_NOFOLLOW | libc::O_RDWR | libc::O_CREAT
} else {
libc::O_NOFOLLOW | libc::O_RDONLY
};
let mode = libc::S_IRUSR | libc::S_IWUSR | libc::S_IRGRP | libc::S_IROTH;
unsafe {
let fd = cerr(libc::openat(
parent.as_raw_fd(),
file_name.as_ptr(),
flags,
c_uint::from(mode),
))?;
Ok(OwnedFd::from_raw_fd(fd))
}
}
fn faccess_at(parent: BorrowedFd, path: &CStr, mode: c_int, flags: c_int) -> io::Result<()> {
cerr(unsafe { libc::faccessat(parent.as_raw_fd(), path.as_ptr(), mode, flags) }).map(|_| ())
}
pub fn secure_open_for_sudoedit(
path: impl AsRef<Path>,
current_user: &CurrentUser,
target_user: &User,
target_group: &Group,
) -> io::Result<File> {
if current_user.is_root() {
sudo_call(target_user, target_group, || {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
})?
} else {
traversed_secure_open(path, current_user, target_user, target_group)
}
}
fn traversed_secure_open(
path: impl AsRef<Path>,
#[cfg(not(test))] forbidden_user: &CurrentUser,
#[cfg(test)] forbidden_user: &User,
target_user: &User,
target_group: &Group,
) -> io::Result<File> {
let path = path.as_ref();
let Some(file_name) = path.file_name() else {
return Err(io::Error::new(
ErrorKind::InvalidInput,
xlat!("invalid path"),
));
};
let mut components = path.parent().unwrap_or(Path::new("")).components();
if components.next() != Some(Component::RootDir) {
return Err(io::Error::new(
ErrorKind::InvalidInput,
xlat!("path must be absolute"),
));
}
let user_cannot_write = |file: &File| -> io::Result<()> {
let meta = file.metadata()?;
let perms = meta.permissions().mode();
if meta.uid() == forbidden_user.uid.inner() {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
xlat!("cannot open a file in a path writable by the user"),
));
}
let user_has_write_perms = if cfg!(test) {
perms & mode(Category::World, Op::Write) != 0
|| (perms & mode(Category::Group, Op::Write) != 0)
&& forbidden_user.in_group_by_gid(GroupId::new(meta.gid()))
|| (perms & mode(Category::Owner, Op::Write) != 0)
&& forbidden_user.uid.inner() == meta.uid()
} else {
faccess_at(file.as_fd(), c"", libc::W_OK, libc::AT_EMPTY_PATH).is_ok()
};
if user_has_write_perms {
Err(io::Error::new(
ErrorKind::PermissionDenied,
xlat!("cannot open a file in a path writable by the user"),
))
} else {
Ok(())
}
};
let mut cur = File::open("/")?;
user_cannot_write(&cur)?;
for component in components {
let dir: CString = match component {
Component::Normal(dir) => CString::new(dir.as_bytes())?,
Component::CurDir => c".".to_owned(),
Component::ParentDir => c"..".to_owned(),
_ => {
return Err(io::Error::new(
ErrorKind::InvalidInput,
xlat!("error in provided path"),
));
}
};
sudo_call(target_user, target_group, || {
cur = open_at(cur.as_fd(), &dir, false)?.into();
io::Result::Ok(())
})??;
user_cannot_write(&cur)?;
}
sudo_call(target_user, target_group, || {
cur = open_at(cur.as_fd(), &CString::new(file_name.as_bytes())?, true)?.into();
io::Result::Ok(())
})??;
user_cannot_write(&cur)?;
Ok(cur)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn secure_open_is_predictable() {
assert!(std::fs::File::open("/etc/hosts").is_ok());
assert!(secure_open_sudoers("/etc/hosts", false).is_ok());
assert!(std::fs::File::open("/tmp").is_ok());
assert!(secure_open_sudoers("/tmp", false).is_err());
#[cfg(target_os = "linux")]
{
if std::fs::File::open("/var/log/wtmp").is_ok() {
assert!(secure_open_sudoers("/var/log/wtmp", false).is_err());
}
}
assert!(std::fs::File::open("/etc/shadow").is_err());
assert!(secure_open_sudoers("/etc/shadow", false).is_err());
}
#[test]
fn test_secure_open_cookie_file() {
assert!(secure_open_cookie_file("/etc/hosts").is_err());
}
#[test]
fn test_traverse_secure_open_negative() {
use crate::common::resolve::CurrentUser;
let root = User::from_name(c"root").unwrap().unwrap();
let user = CurrentUser::resolve().unwrap();
assert!(traversed_secure_open("/", &root, &user, &user.group()).is_err());
assert!(traversed_secure_open("./hello.txt", &root, &user, &user.group()).is_err());
assert!(traversed_secure_open("/hello.txt", &root, &user, &user.group()).is_err());
assert!(traversed_secure_open("/tmp", &user, &user, &user.group()).is_err());
assert!(traversed_secure_open("/tmp/foo/hello.txt", &user, &user, &user.group()).is_err());
assert!(traversed_secure_open("/bin/hello.txt", &user, &user, &user.group()).is_err());
}
#[test]
fn test_traverse_secure_open_positive() {
use crate::common::resolve::CurrentUser;
use crate::system::{GroupId, UserId};
let user = CurrentUser::resolve().unwrap();
let other_user = CurrentUser::fake(User {
uid: UserId::new(1042),
gid: GroupId::new(1042),
name: "test".into(),
home: "/home/test".into(),
shell: "/bin/sh".into(),
groups: vec![],
});
let path = std::env::current_dir()
.unwrap()
.join("sudo-rs-test-file.txt");
let file = traversed_secure_open(&path, &other_user, &user, &user.group()).unwrap();
if file.metadata().is_ok_and(|meta| meta.len() == 0) {
std::fs::remove_file(path).unwrap();
}
}
}