Descriptor-anchored Secured Filesystem Operations

This commit is contained in:
Alexey
2026-09-17 22:45:48 +03:00
parent 9a683d8b3d
commit 02f66c542e
41 changed files with 2997 additions and 1759 deletions
+2
View File
@@ -1,6 +1,8 @@
//! Utils
pub mod ip;
#[cfg(unix)]
pub mod secure_fs;
pub mod time;
#[cfg(unix)]
pub mod trusted_command;
+20
View File
@@ -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;
+159
View File
@@ -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(&current)?;
}
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(&current, 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(&current, name, mode) {
Ok(()) | Err(nix::errno::Errno::EEXIST) => {}
Err(error) => return Err(errno_to_io(error)),
}
openat(&current, 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)
}
+78
View File
@@ -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);
}
+220
View File
@@ -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)
}