mirror of
https://github.com/telemt/telemt.git
synced 2026-11-02 05:42:00 +03:00
Descriptor-anchored Secured Filesystem Operations
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
//! Utils
|
||||
|
||||
pub mod ip;
|
||||
#[cfg(unix)]
|
||||
pub mod secure_fs;
|
||||
pub mod time;
|
||||
#[cfg(unix)]
|
||||
pub mod trusted_command;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Descriptor-anchored filesystem operations for privileged runtime paths.
|
||||
//!
|
||||
//! Submodules:
|
||||
//! - `path`: symlink-free directory traversal and anchored path ownership
|
||||
//! - `write`: regular-file opening and durable atomic replacement
|
||||
|
||||
mod path;
|
||||
mod write;
|
||||
|
||||
pub(crate) use path::{
|
||||
AnchoredPath, chdir_nofollow_or_create, open_dir_nofollow,
|
||||
open_dir_nofollow_or_create,
|
||||
};
|
||||
pub(crate) use write::{
|
||||
atomic_replace, atomic_replace_async, open_append_regular, open_append_regular_at,
|
||||
read_regular_limited, read_regular_limited_async,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use nix::fcntl::{OFlag, open, openat};
|
||||
use nix::sys::stat::{Mode, mkdirat};
|
||||
|
||||
const DIRECTORY_FLAGS: OFlag = OFlag::O_RDONLY
|
||||
.union(OFlag::O_DIRECTORY)
|
||||
.union(OFlag::O_NOFOLLOW)
|
||||
.union(OFlag::O_CLOEXEC);
|
||||
|
||||
/// An immutable parent-directory descriptor paired with one final path component.
|
||||
pub(crate) struct AnchoredPath {
|
||||
parent: OwnedFd,
|
||||
name: OsString,
|
||||
}
|
||||
|
||||
impl AnchoredPath {
|
||||
/// Opens every parent component without following symbolic links.
|
||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
||||
Self::open_with_parent_creation(path, None)
|
||||
}
|
||||
|
||||
/// Creates missing parent directories while traversing without symbolic links.
|
||||
pub(crate) fn open_creating_parents(path: &Path, mode: u32) -> io::Result<Self> {
|
||||
Self::open_with_parent_creation(path, Some(mode))
|
||||
}
|
||||
|
||||
/// Opens a parent chain that cannot be renamed by group or world users.
|
||||
pub(crate) fn open_trusted_parent(path: &Path) -> io::Result<Self> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
|
||||
.to_os_string();
|
||||
let parent_path = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let parent = open_trusted_dir_nofollow(parent_path)?;
|
||||
Ok(Self { parent, name })
|
||||
}
|
||||
|
||||
fn open_with_parent_creation(path: &Path, create_mode: Option<u32>) -> io::Result<Self> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
|
||||
.to_os_string();
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let parent = match create_mode {
|
||||
Some(mode) => open_or_create_dir_nofollow(parent, mode)?,
|
||||
None => open_dir_nofollow(parent)?,
|
||||
};
|
||||
Ok(Self { parent, name })
|
||||
}
|
||||
|
||||
/// Returns the anchored parent descriptor.
|
||||
pub(crate) fn parent(&self) -> &OwnedFd {
|
||||
&self.parent
|
||||
}
|
||||
|
||||
/// Returns the single final component resolved relative to the parent descriptor.
|
||||
pub(crate) fn name(&self) -> &OsStr {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a directory by walking every component with `O_NOFOLLOW`.
|
||||
pub(crate) fn open_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
|
||||
open_dir_components(path, None, false)
|
||||
}
|
||||
|
||||
fn open_or_create_dir_nofollow(path: &Path, mode: u32) -> io::Result<OwnedFd> {
|
||||
open_dir_components(path, Some(mode), false)
|
||||
}
|
||||
|
||||
/// Opens a directory after securely creating any missing components.
|
||||
pub(crate) fn open_dir_nofollow_or_create(path: &Path, mode: u32) -> io::Result<OwnedFd> {
|
||||
open_or_create_dir_nofollow(path, mode)
|
||||
}
|
||||
|
||||
/// Opens a directory only when its entire path is owned by root or the effective user.
|
||||
fn open_trusted_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
|
||||
open_dir_components(path, None, true)
|
||||
}
|
||||
|
||||
fn open_dir_components(
|
||||
path: &Path,
|
||||
create_mode: Option<u32>,
|
||||
require_trusted: bool,
|
||||
) -> io::Result<OwnedFd> {
|
||||
let start = if path.is_absolute() {
|
||||
Path::new("/")
|
||||
} else {
|
||||
Path::new(".")
|
||||
};
|
||||
let mut current = open(start, DIRECTORY_FLAGS, Mode::empty()).map_err(errno_to_io)?;
|
||||
if require_trusted {
|
||||
validate_trusted_directory(¤t)?;
|
||||
}
|
||||
|
||||
for component in path.components() {
|
||||
let name = match component {
|
||||
Component::RootDir | Component::CurDir => continue,
|
||||
Component::Normal(name) => name,
|
||||
Component::ParentDir => OsStr::new(".."),
|
||||
Component::Prefix(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"unsupported path prefix",
|
||||
));
|
||||
}
|
||||
};
|
||||
let next = match openat(¤t, name, DIRECTORY_FLAGS, Mode::empty()) {
|
||||
Ok(descriptor) => descriptor,
|
||||
Err(nix::errno::Errno::ENOENT) if create_mode.is_some() => {
|
||||
let mode = Mode::from_bits_truncate(create_mode.unwrap_or(0o750));
|
||||
match mkdirat(¤t, name, mode) {
|
||||
Ok(()) | Err(nix::errno::Errno::EEXIST) => {}
|
||||
Err(error) => return Err(errno_to_io(error)),
|
||||
}
|
||||
openat(¤t, name, DIRECTORY_FLAGS, Mode::empty()).map_err(errno_to_io)?
|
||||
}
|
||||
Err(error) => return Err(errno_to_io(error)),
|
||||
};
|
||||
if require_trusted {
|
||||
validate_trusted_directory(&next)?;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn validate_trusted_directory(descriptor: &OwnedFd) -> io::Result<()> {
|
||||
let file = std::fs::File::from(descriptor.try_clone()?);
|
||||
let metadata = file.metadata()?;
|
||||
let effective_uid = nix::unistd::Uid::effective().as_raw();
|
||||
if !metadata.is_dir()
|
||||
|| (metadata.uid() != 0 && metadata.uid() != effective_uid)
|
||||
|| metadata.permissions().mode() & 0o022 != 0
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"directory path is not owned and protected from group/world writes",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates missing components and changes cwd to the exact opened directory inode.
|
||||
pub(crate) fn chdir_nofollow_or_create(path: &Path, mode: u32) -> io::Result<()> {
|
||||
let descriptor = open_or_create_dir_nofollow(path, mode)?;
|
||||
nix::unistd::fchdir(&descriptor).map_err(errno_to_io)
|
||||
}
|
||||
|
||||
pub(super) fn errno_to_io(error: nix::errno::Errno) -> io::Error {
|
||||
io::Error::from_raw_os_error(error as i32)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
use super::path::AnchoredPath;
|
||||
use super::write::atomic_replace_after_anchor;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn directory_walk_rejects_intermediate_symlink() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let real = directory.path().join("real");
|
||||
let link = directory.path().join("link");
|
||||
std::fs::create_dir(&real).unwrap();
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
assert!(open_dir_nofollow(&link).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_replace_does_not_follow_final_symlink() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let sentinel = directory.path().join("sentinel");
|
||||
let target = directory.path().join("target");
|
||||
std::fs::write(&sentinel, b"preserve").unwrap();
|
||||
symlink(&sentinel, &target).unwrap();
|
||||
|
||||
atomic_replace(&target, b"replacement", 0o600).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_open_rejects_final_symlink() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let sentinel = directory.path().join("sentinel");
|
||||
let target = directory.path().join("target");
|
||||
std::fs::write(&sentinel, b"preserve").unwrap();
|
||||
symlink(&sentinel, &target).unwrap();
|
||||
|
||||
assert!(open_append_regular(&target, 0o640).is_err());
|
||||
assert_eq!(std::fs::read(&sentinel).unwrap(), b"preserve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_parent_rejects_group_writable_directory() {
|
||||
let current = std::env::current_dir().unwrap();
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix("telemt-insecure-parent-")
|
||||
.tempdir_in(current)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o770)).unwrap();
|
||||
|
||||
assert!(AnchoredPath::open_trusted_parent(&directory.path().join("listener.sock")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchored_replace_survives_parent_path_substitution() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let original = directory.path().join("original");
|
||||
let moved = directory.path().join("moved");
|
||||
let redirect = directory.path().join("redirect");
|
||||
std::fs::create_dir(&original).unwrap();
|
||||
std::fs::create_dir(&redirect).unwrap();
|
||||
let target = original.join("state");
|
||||
let anchored = AnchoredPath::open(&target).unwrap();
|
||||
std::fs::rename(&original, &moved).unwrap();
|
||||
symlink(&redirect, &original).unwrap();
|
||||
|
||||
atomic_replace_after_anchor(&anchored, b"anchored", 0o600).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(moved.join("state")).unwrap(), b"anchored");
|
||||
assert!(!redirect.join("state").exists());
|
||||
let mode = std::fs::metadata(moved.join("state"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::io::{self, Read, Write};
|
||||
use std::ffi::OsStr;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::path::Path;
|
||||
|
||||
use nix::fcntl::{OFlag, openat, renameat};
|
||||
use nix::sys::stat::Mode;
|
||||
use nix::unistd::{UnlinkatFlags, fsync, unlinkat};
|
||||
|
||||
use super::path::{AnchoredPath, errno_to_io};
|
||||
|
||||
fn open_regular_at(
|
||||
anchored: &AnchoredPath,
|
||||
flags: OFlag,
|
||||
mode: u32,
|
||||
) -> io::Result<std::fs::File> {
|
||||
let descriptor = openat(
|
||||
anchored.parent(),
|
||||
anchored.name(),
|
||||
flags | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
|
||||
Mode::from_bits_truncate(mode),
|
||||
)
|
||||
.map_err(errno_to_io)?;
|
||||
let file = std::fs::File::from(descriptor);
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"target must be a regular file",
|
||||
));
|
||||
}
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if metadata.nlink() != 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"target must have exactly one directory entry",
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Reads a regular file through an anchored parent with an allocation bound.
|
||||
pub(crate) fn read_regular_limited(path: &Path, max_bytes: usize) -> io::Result<Vec<u8>> {
|
||||
let anchored = AnchoredPath::open(path)?;
|
||||
let mut file = open_regular_at(
|
||||
&anchored,
|
||||
OFlag::O_RDONLY | OFlag::O_NONBLOCK,
|
||||
Mode::empty().bits(),
|
||||
)?;
|
||||
let before = file.metadata()?;
|
||||
if before.len() > max_bytes as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file exceeds configured size limit",
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(before.len() as usize);
|
||||
Read::take(&mut file, max_bytes.saturating_add(1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.len() > max_bytes {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file exceeds configured size limit",
|
||||
));
|
||||
}
|
||||
let after = file.metadata()?;
|
||||
if !same_file_version(&before, &after) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file changed while it was read",
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Reads a bounded regular file on the blocking pool.
|
||||
pub(crate) async fn read_regular_limited_async(
|
||||
path: std::path::PathBuf,
|
||||
max_bytes: usize,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
tokio::task::spawn_blocking(move || read_regular_limited(&path, max_bytes))
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("secure reader task failed: {error}")))?
|
||||
}
|
||||
|
||||
fn same_file_version(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
left.dev() == right.dev()
|
||||
&& left.ino() == right.ino()
|
||||
&& left.len() == right.len()
|
||||
&& left.mtime() == right.mtime()
|
||||
&& left.mtime_nsec() == right.mtime_nsec()
|
||||
&& left.ctime() == right.ctime()
|
||||
&& left.ctime_nsec() == right.ctime_nsec()
|
||||
}
|
||||
|
||||
/// Opens an append-only regular file without following path components or hard links.
|
||||
pub(crate) fn open_append_regular(path: &Path, mode: u32) -> io::Result<std::fs::File> {
|
||||
let anchored = AnchoredPath::open_creating_parents(path, 0o750)?;
|
||||
open_regular_at(
|
||||
&anchored,
|
||||
OFlag::O_WRONLY | OFlag::O_APPEND | OFlag::O_CREAT,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens one append-only file relative to an already anchored directory.
|
||||
pub(crate) fn open_append_regular_at<Fd: AsFd>(
|
||||
parent: Fd,
|
||||
name: &OsStr,
|
||||
mode: u32,
|
||||
) -> io::Result<std::fs::File> {
|
||||
if Path::new(name).components().count() != 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"anchored file name must contain one component",
|
||||
));
|
||||
}
|
||||
let descriptor = openat(
|
||||
parent,
|
||||
name,
|
||||
OFlag::O_WRONLY
|
||||
| OFlag::O_APPEND
|
||||
| OFlag::O_CREAT
|
||||
| OFlag::O_NOFOLLOW
|
||||
| OFlag::O_CLOEXEC,
|
||||
Mode::from_bits_truncate(mode),
|
||||
)
|
||||
.map_err(errno_to_io)?;
|
||||
let file = std::fs::File::from(descriptor);
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"target must be a regular file",
|
||||
));
|
||||
}
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if metadata.nlink() != 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"target must have exactly one directory entry",
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Durably replaces a file through a same-directory descriptor-anchored rename.
|
||||
pub(crate) fn atomic_replace(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> {
|
||||
let anchored = AnchoredPath::open_creating_parents(path, 0o750)?;
|
||||
atomic_replace_anchored(&anchored, contents, mode)
|
||||
}
|
||||
|
||||
/// Durably replaces a file on the blocking pool.
|
||||
pub(crate) async fn atomic_replace_async(
|
||||
path: std::path::PathBuf,
|
||||
contents: Vec<u8>,
|
||||
mode: u32,
|
||||
) -> io::Result<()> {
|
||||
tokio::task::spawn_blocking(move || atomic_replace(&path, &contents, mode))
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("secure writer task failed: {error}")))?
|
||||
}
|
||||
|
||||
fn atomic_replace_anchored(
|
||||
anchored: &AnchoredPath,
|
||||
contents: &[u8],
|
||||
mode: u32,
|
||||
) -> io::Result<()> {
|
||||
let temp_name = format!(".telemt.tmp-{}", rand::random::<u64>());
|
||||
let descriptor = openat(
|
||||
anchored.parent(),
|
||||
temp_name.as_str(),
|
||||
OFlag::O_WRONLY
|
||||
| OFlag::O_CREAT
|
||||
| OFlag::O_EXCL
|
||||
| OFlag::O_NOFOLLOW
|
||||
| OFlag::O_CLOEXEC,
|
||||
Mode::from_bits_truncate(mode),
|
||||
)
|
||||
.map_err(errno_to_io)?;
|
||||
let result = write_and_publish(descriptor, anchored, &temp_name, contents);
|
||||
if result.is_err() {
|
||||
let _ = unlinkat(
|
||||
anchored.parent(),
|
||||
temp_name.as_str(),
|
||||
UnlinkatFlags::NoRemoveDir,
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn write_and_publish(
|
||||
descriptor: OwnedFd,
|
||||
anchored: &AnchoredPath,
|
||||
temp_name: &str,
|
||||
contents: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let mut file = std::fs::File::from(descriptor);
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
renameat(
|
||||
anchored.parent(),
|
||||
temp_name,
|
||||
anchored.parent(),
|
||||
anchored.name(),
|
||||
)
|
||||
.map_err(errno_to_io)?;
|
||||
fsync(anchored.parent()).map_err(errno_to_io)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn atomic_replace_after_anchor(
|
||||
anchored: &AnchoredPath,
|
||||
contents: &[u8],
|
||||
mode: u32,
|
||||
) -> io::Result<()> {
|
||||
atomic_replace_anchored(anchored, contents, mode)
|
||||
}
|
||||
Reference in New Issue
Block a user