Confine the profiler capture directory to the log root

The directory given to `profile tick on` travelled from the control
socket straight into a root create_dir_all with no validation. The
socket is reachable by the fips group, which docs/reference/security.md
writes down as strictly weaker than root, so a group member could create
a root-owned directory anywhere on the filesystem, including a path a
later privileged component reads.

A privileged daemon now resolves --dir against /var/log/fips: absolute,
no `..` component, and still under the root once every existing ancestor
has been followed through its symlinks, so a symlinked parent cannot
launder a lexically clean path. Plain canonicalize cannot do this on its
own, since the directory being created does not exist yet; the resolver
canonicalizes the deepest existing ancestor and re-appends the tail. An
unprivileged daemon crosses no boundary and takes --dir as given, which
keeps the documented non-root `cargo run` capture working. Whether the
process is privileged is a parameter rather than a geteuid() call inside
the resolver, so the rules are exercised without depending on the uid of
whoever ran the tests, and the lifecycle tests use a start_in that takes
an already-resolved directory for the same reason.

The sink itself is no longer written over whatever is at its path. The
name is a one-second UTC stamp and therefore predictable, so File::create
would have followed a symlink pre-planted at the next name and truncated
any real file it found. The file is created only if it does not exist,
and a capture started in the same second as a previous one takes the next
free suffix rather than failing: stop-then-start inside one second is an
ordinary sequence that used to succeed by truncating. Capture files are
created private to their owner and the capture directory no longer
inherits a permissive umask, since a capture carries the node npub,
build, platform and a timing series.

Confining a root daemon to a different log root is no longer possible
and cli-fipsctl.md drops that use of --dir. This affects only a
--features profiling build; the subcommand is absent from a stock
package.
This commit is contained in:
Johnathan Corgan
2026-08-23 16:53:28 +01:00
parent 298528a39c
commit 6eff0accce
3 changed files with 399 additions and 18 deletions
+29
View File
@@ -540,6 +540,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
other packaging paths did, so the contact of record in the new `.pkg` artifact other packaging paths did, so the contact of record in the new `.pkg` artifact
would have been unreachable at its first release. would have been unreachable at its first release.
### Security
#### Tick profiler
- The `--dir` given to `profile tick on` is now confined to `/var/log/fips`
when the daemon runs as root. The control socket is reachable by the `fips`
group, which the security model writes down as strictly weaker than root, yet
the directory travelled from the socket straight into a root `create_dir_all`
with no validation: a group member could create a root-owned directory
anywhere on the filesystem, including a path a later privileged component
reads. The path must now be absolute, must contain no `..`, and must still
resolve under the root once every existing ancestor has been followed through
its symlinks, so a symlinked parent does not launder a lexically clean path.
A daemon that is not running as root crosses no such boundary and takes
`--dir` as given, which keeps the documented non-root `cargo run` capture
working; the flag can no longer point a root daemon at a different log root,
which was its other documented use. This only ever affected a
`--features profiling` build: the subcommand is absent from a stock package.
- A capture file is no longer written over whatever is already at its path. The
name is a one-second UTC stamp and therefore predictable, so `File::create`
would have followed a symlink pre-planted at the next name, and it truncated
any real file it found. The file is created only if it does not exist, and a
capture started in the same second as a previous one takes the next free
`-N` suffix instead of failing. Capture files are created private to their
owner and the capture directory is no longer left world-accessible by a
permissive umask; a capture carries the node npub, build, platform and a
timing series.
## [0.4.2] - unreleased ## [0.4.2] - unreleased
### Added ### Added
+15 -2
View File
@@ -241,9 +241,22 @@ package does not carry this subcommand and a stock daemon reports
| Flag | Argument | Default | Description | | Flag | Argument | Default | Description |
| ---- | -------- | ------- | ----------- | | ---- | -------- | ------- | ----------- |
| `--dir` | directory path | `/var/log/fips` | Where to write the capture. Created if absent. Use it to profile a non-root `cargo run`, or on a platform whose log root differs. | | `--dir` | directory path | `/var/log/fips` | Where to write the capture. Created if absent. Use it to profile a non-root `cargo run`. |
One file is written per capture, named `profile-<UTC timestamp>.tsv`. Against a daemon running as root, `--dir` must name an absolute path
under `/var/log/fips`, and a path that resolves outside it through a
symlinked parent is refused. The control socket is reachable by the
`fips` group, which the security model treats as strictly weaker than
root, so a group member cannot steer a root directory creation at an
arbitrary path. A daemon that is not running as root crosses no such
boundary and takes `--dir` as given, which is what keeps a non-root
`cargo run` capture working. Confining a root daemon's captures to a
different log root is not currently supported.
One file is written per capture, named `profile-<UTC timestamp>.tsv`,
with `-1`, `-2` and so on appended if a capture in the same second
already claimed the name. The capture file is created private to its
owner and is never written over an existing file.
It opens with a `#`-prefixed header block (node npub, build version, It opens with a `#`-prefixed header block (node npub, build version,
platform, configured tick period, flush interval, byte cap, start platform, configured tick period, flush interval, byte cap, start
time), then a tab-separated column header, then one row per measured time), then a tab-separated column header, then one row per measured
+354 -15
View File
@@ -12,7 +12,7 @@
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::{Component, Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -20,9 +20,27 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::recorder; use super::recorder;
use super::writer; use super::writer;
/// Default sink directory. Overridable per capture with `--dir`. /// Default sink directory, and the root a privileged daemon confines `--dir`
/// to. Overridable per capture with `--dir`, within that root.
pub(crate) const DEFAULT_DIR: &str = "/var/log/fips"; pub(crate) const DEFAULT_DIR: &str = "/var/log/fips";
/// How many same-second filename collisions a capture steps around before
/// giving up. Stop-then-start inside one second is an ordinary operator
/// sequence and the stamp has one-second granularity, so a bare failure there
/// would be a regression. Raising this only widens how many restarts one
/// second can hold; lowering it turns a fast restart back into an error.
const NAME_COLLISION_RETRIES: u32 = 16;
/// Mode for a created capture file. A capture carries the node npub, build,
/// platform and a timing series, so it is not world-readable.
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
/// Mode for a created capture directory. `mkdir` can only tighten this with
/// the umask, never loosen it.
#[cfg(unix)]
const DIR_MODE: u32 = 0o750;
/// Writer flush interval. /// Writer flush interval.
pub(crate) const INTERVAL: Duration = Duration::from_secs(10); pub(crate) const INTERVAL: Duration = Duration::from_secs(10);
@@ -102,14 +120,133 @@ fn reap() {
} }
} }
/// Arm a capture. /// True when the process runs with root's privileges.
///
/// This is what makes an unconstrained `--dir` a privilege crossing: the
/// control socket is reachable by the `fips` group, which the security model
/// writes down as strictly weaker than root, so a group member must not be
/// able to steer a root `create_dir_all` at an arbitrary path.
#[cfg(unix)]
fn running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
/// No control socket and no `fips` group off unix, so there is no weaker
/// principal to confine and the parameter keeps its original meaning.
#[cfg(not(unix))]
fn running_as_root() -> bool {
false
}
/// Resolve a requested capture directory against the root it must stay under.
///
/// `privileged` is a parameter rather than a `geteuid()` call so the rules can
/// be exercised without depending on the uid of whoever ran the tests.
///
/// - No `--dir` keeps today's default, `root`.
/// - An unprivileged daemon crosses no boundary: it can already write wherever
/// the invoking user can, so the path is taken as given. This is what keeps
/// the documented non-root `cargo run` capture working.
/// - Otherwise the path must be absolute, must contain no `..` component, and
/// must resolve under `root` once every existing ancestor has been followed
/// through its symlinks.
///
/// Accepted: resolution is check-then-act. A symlink planted between the check
/// here and the `create_dir_all` in `open_sink` would escape the root. Inside
/// `/var/log/fips` only root can plant one, which is the principal already
/// being trusted.
fn resolve_dir_under(dir: Option<&str>, root: &Path, privileged: bool) -> Result<PathBuf, String> {
let Some(dir) = dir else {
return Ok(root.to_path_buf());
};
if !privileged {
return Ok(PathBuf::from(dir));
}
let requested = Path::new(dir);
if !requested.is_absolute() {
return Err(format!(
"profile directory must be an absolute path under {}: {dir}",
root.display()
));
}
if requested
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(format!(
"profile directory must not contain `..` components: {dir}"
));
}
let resolved = resolve_through_existing(requested)?;
let root = resolve_through_existing(root)?;
if !resolved.starts_with(&root) {
return Err(format!(
"profile directory must be under {}: {dir}",
root.display()
));
}
Ok(resolved)
}
/// Canonicalize the deepest existing ancestor of `path` and re-append the tail
/// that does not exist yet.
///
/// Plain `canonicalize` returns `NotFound` for the directory a capture is
/// being asked to create, which is the ordinary case, so it cannot be used on
/// its own. Following the existing ancestors is what catches a lexically clean
/// path whose parent is a symlink out of the root.
fn resolve_through_existing(path: &Path) -> Result<PathBuf, String> {
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut probe = path.to_path_buf();
loop {
if let Ok(base) = probe.canonicalize() {
let mut out = base;
for part in tail.iter().rev() {
out.push(part);
}
return Ok(out);
}
let Some(name) = probe.file_name().map(|n| n.to_os_string()) else {
return Err(format!(
"cannot resolve profile directory {}",
path.display()
));
};
tail.push(name);
if !probe.pop() {
return Err(format!(
"cannot resolve profile directory {}",
path.display()
));
}
}
}
/// Arm a capture, confining `--dir` to the capture root.
/// ///
/// Opens the sink first and only then starts the writer, so a bad `--dir` is /// Opens the sink first and only then starts the writer, so a bad `--dir` is
/// reported to the caller rather than logged into the void. /// reported to the caller rather than logged into the void. The directory is
/// resolved before the capture slot is claimed, so a rejected path leaves the
/// slot free.
pub(crate) fn start( pub(crate) fn start(
dir: Option<&str>, dir: Option<&str>,
node_npub: &str, node_npub: &str,
tick_period_secs: u64, tick_period_secs: u64,
) -> Result<serde_json::Value, String> {
let dir = resolve_dir_under(dir, Path::new(DEFAULT_DIR), running_as_root())?;
start_in(&dir, node_npub, tick_period_secs)
}
/// Arm a capture in an already-resolved directory.
///
/// Split out from `start` so the confinement rules live in one place and the
/// lifecycle can be exercised without them.
pub(crate) fn start_in(
dir: &Path,
node_npub: &str,
tick_period_secs: u64,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
claim()?; claim()?;
@@ -210,25 +347,80 @@ pub(crate) fn shutdown() {
} }
} }
/// Create the capture directory, giving it a mode rather than inheriting
/// whatever a permissive umask allows.
fn create_capture_dir(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(DIR_MODE);
}
builder.create(dir)
}
/// Create the capture file, refusing to follow or truncate anything already at
/// the path.
///
/// The basename is a one-second UTC stamp and therefore predictable, so
/// `File::create` would follow a symlink pre-planted at the next name.
/// `create_new` refuses any existing entry; a same-second restart, which used
/// to succeed by silently truncating the previous capture, steps to the next
/// free suffix instead of failing.
fn create_capture_file(dir: &Path, stamp: &str) -> Result<(File, PathBuf), String> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(FILE_MODE);
}
let mut last = None;
for n in 0..=NAME_COLLISION_RETRIES {
let name = if n == 0 {
format!("profile-{stamp}.tsv")
} else {
format!("profile-{stamp}-{n}.tsv")
};
let path = dir.join(name);
match opts.open(&path) {
Ok(file) => return Ok((file, path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
last = Some((path, e));
}
Err(e) => {
return Err(format!(
"cannot create profile file {}: {e}",
path.display()
));
}
}
}
let (path, e) = last.expect("the loop runs at least once");
Err(format!(
"cannot create profile file {}: {e}",
path.display()
))
}
/// Create the sink file and write its header block. Returns the open file, its /// Create the sink file and write its header block. Returns the open file, its
/// path, and the number of header bytes written. /// path, and the number of header bytes written.
fn open_sink( fn open_sink(
dir: Option<&str>, dir: &Path,
node_npub: &str, node_npub: &str,
tick_period_secs: u64, tick_period_secs: u64,
) -> Result<(File, PathBuf, u64), String> { ) -> Result<(File, PathBuf, u64), String> {
let dir = PathBuf::from(dir.unwrap_or(DEFAULT_DIR)); create_capture_dir(dir)
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot use profile directory {}: {e}", dir.display()))?; .map_err(|e| format!("cannot use profile directory {}: {e}", dir.display()))?;
let start_unix = SystemTime::now() let start_unix = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let path = dir.join(format!("profile-{}.tsv", compact_utc(start_unix))); let (mut file, path) = create_capture_file(dir, &compact_utc(start_unix))?;
let mut file = File::create(&path)
.map_err(|e| format!("cannot create profile file {}: {e}", path.display()))?;
let header = format!( let header = format!(
"# fips tick profile\n\ "# fips tick profile\n\
@@ -330,15 +522,14 @@ mod tests {
fn capture_round_trip_writes_header_and_rows() { fn capture_round_trip_writes_header_and_rows() {
let _guard = crate::instr::test_serial(); let _guard = crate::instr::test_serial();
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let dir_str = dir.path().to_str().unwrap().to_string();
let started = start(Some(&dir_str), "npub1test", 1).expect("start"); let started = start_in(dir.path(), "npub1test", 1).expect("start");
assert_eq!(started["state"], "running"); assert_eq!(started["state"], "running");
assert!(gate(), "gate must be armed while running"); assert!(gate(), "gate must be armed while running");
let path = PathBuf::from(started["path"].as_str().unwrap()); let path = PathBuf::from(started["path"].as_str().unwrap());
// A second `on` is refused while one is running, and names the file. // A second `on` is refused while one is running, and names the file.
let refused = start(Some(&dir_str), "npub1test", 1).unwrap_err(); let refused = start_in(dir.path(), "npub1test", 1).unwrap_err();
assert!(refused.contains(&path.display().to_string()), "{refused}"); assert!(refused.contains(&path.display().to_string()), "{refused}");
// Feed one observation so the drained rows are not all zero. // Feed one observation so the drained rows are not all zero.
@@ -400,7 +591,11 @@ mod tests {
#[test] #[test]
fn start_fails_loudly_on_an_unwritable_directory() { fn start_fails_loudly_on_an_unwritable_directory() {
let _guard = crate::instr::test_serial(); let _guard = crate::instr::test_serial();
let err = start(Some("/proc/fips-profile-should-not-exist"), "npub1test", 1) let err = start_in(
Path::new("/proc/fips-profile-should-not-exist"),
"npub1test",
1,
)
.expect_err("must fail"); .expect_err("must fail");
assert!(err.contains("profile directory"), "{err}"); assert!(err.contains("profile directory"), "{err}");
// The failed attempt must leave the slot free for the next try. // The failed attempt must leave the slot free for the next try.
@@ -408,6 +603,150 @@ mod tests {
assert!(!gate()); assert!(!gate());
} }
// ========================================================================
// `--dir` confinement
//
// All of these force `privileged = true` rather than reading the uid, so
// their verdict does not depend on who ran the suite.
// ========================================================================
#[test]
fn profile_dir_rejects_a_parent_traversal_escape() {
let root = tempfile::tempdir().expect("tempdir");
let escape = root.path().join("..").join("..").join("etc");
let err = resolve_dir_under(Some(escape.to_str().unwrap()), root.path(), true)
.expect_err("a `..` escape must be refused");
assert!(err.contains(".."), "{err}");
}
#[test]
fn profile_dir_rejects_a_relative_path() {
let root = tempfile::tempdir().expect("tempdir");
let err = resolve_dir_under(Some("sub"), root.path(), true)
.expect_err("a relative path must be refused");
assert!(err.contains("absolute"), "{err}");
}
#[test]
fn profile_dir_rejects_an_absolute_path_outside_the_root() {
let root = tempfile::tempdir().expect("tempdir");
let err = resolve_dir_under(Some("/etc/cron.d"), root.path(), true)
.expect_err("a path outside the root must be refused");
assert!(err.contains("must be under"), "{err}");
}
/// The lexically clean escape: every component is innocent and an existing
/// ancestor is a symlink pointing out of the root.
#[test]
#[cfg(unix)]
fn profile_dir_rejects_a_symlinked_ancestor_pointing_out_of_the_root() {
let root = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("tempdir");
let link = root.path().join("link");
std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
let asked = link.join("x");
let err = resolve_dir_under(Some(asked.to_str().unwrap()), root.path(), true)
.expect_err("a symlinked ancestor must not escape the root");
assert!(err.contains("must be under"), "{err}");
}
#[test]
fn profile_dir_accepts_the_root_and_a_subdirectory_that_does_not_exist_yet() {
let root = tempfile::tempdir().expect("tempdir");
resolve_dir_under(Some(root.path().to_str().unwrap()), root.path(), true)
.expect("the root itself must be allowed");
// Not yet created: the resolver must not depend on the path existing.
let fresh = root.path().join("run-1");
let got = resolve_dir_under(Some(fresh.to_str().unwrap()), root.path(), true)
.expect("a subdirectory that does not exist yet must be allowed");
assert!(got.ends_with("run-1"), "{}", got.display());
}
#[test]
fn profile_dir_defaults_to_the_root_when_none_is_given() {
let root = tempfile::tempdir().expect("tempdir");
let got = resolve_dir_under(None, root.path(), true).expect("the default must be allowed");
assert_eq!(got, root.path());
}
#[test]
fn profile_dir_takes_the_unprivileged_bypass() {
let root = tempfile::tempdir().expect("tempdir");
let got = resolve_dir_under(Some("/anywhere/at/all"), root.path(), false)
.expect("an unprivileged daemon crosses no boundary");
assert_eq!(got, PathBuf::from("/anywhere/at/all"));
}
/// A pre-planted entry at the predicted capture name must not be followed
/// or truncated. The stamp is one-second granular, so the name is
/// guessable to within a second.
#[test]
fn profile_sink_does_not_truncate_a_preexisting_path_at_the_capture_name() {
let _guard = crate::instr::test_serial();
let dir = tempfile::tempdir().expect("tempdir");
let start_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let squatted = dir.path().join(format!(
"profile-{}.tsv",
compact_utc(start_unix.saturating_sub(1))
));
std::fs::write(&squatted, b"do not truncate me").expect("plant");
// Name the same stamp the squatter used, so the sink collides with it.
let (_file, path) =
create_capture_file(dir.path(), &compact_utc(start_unix.saturating_sub(1)))
.expect("the sink must step around the collision");
assert_ne!(path, squatted, "the sink must not reuse the existing name");
assert_eq!(
std::fs::read_to_string(&squatted).expect("read"),
"do not truncate me",
"an existing file at the capture name must survive"
);
}
/// Stop-then-start inside one second is an ordinary operator sequence and
/// must produce a second capture, not an error.
#[test]
fn profile_sink_uniquifies_a_same_second_restart() {
let _guard = crate::instr::test_serial();
let dir = tempfile::tempdir().expect("tempdir");
let (_first, first_path) = create_capture_file(dir.path(), "20260727T191500Z").unwrap();
let (_second, second_path) = create_capture_file(dir.path(), "20260727T191500Z")
.expect("a same-second restart must not fail");
assert_ne!(first_path, second_path);
assert!(first_path.exists() && second_path.exists());
}
#[test]
#[cfg(unix)]
fn profile_sink_creates_a_private_file_and_directory() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::instr::test_serial();
let root = tempfile::tempdir().expect("tempdir");
let dir = root.path().join("captures");
create_capture_dir(&dir).expect("create dir");
let (_file, path) = create_capture_file(&dir, "20260727T191500Z").expect("create file");
// The umask can only tighten what `mkdir`/`open` were given, so the
// assertion is on the bits that must be absent rather than equality.
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o007,
0,
"the capture directory must not be world-accessible"
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o077,
0,
"the capture file must be private to its owner"
);
}
#[test] #[test]
fn status_reports_the_bounds_it_is_enforcing() { fn status_reports_the_bounds_it_is_enforcing() {
let _guard = crate::instr::test_serial(); let _guard = crate::instr::test_serial();