Skip to content

Commit 4519fe8

Browse files
committed
Keep ACME ownership within filesystem boundary
1 parent dfd1333 commit 4519fe8

4 files changed

Lines changed: 107 additions & 8 deletions

File tree

crates/fluxheim-acme/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ x509-parser = { version = "0.18.1", default-features = false }
5151
fluxheim-common = { path = "../fluxheim-common", features = ["test-support"] }
5252
proptest = "1.9.0"
5353
tokio = { version = "1.52.3", default-features = false, features = ["rt", "time"] }
54+
55+
[target.'cfg(target_os = "linux")'.dev-dependencies]
56+
rustix = { version = "1.1.4", features = ["mount"] }

crates/fluxheim-acme/src/acme_directory.rs

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,21 @@ pub(crate) fn reconcile_private_directory_subtree(
4040
})
4141
.collect::<io::Result<Vec<_>>>()?;
4242

43+
let boundary_device = rustix::fs::fstat(boundary_directory)?.st_dev;
4344
let mut directory = rustix::io::dup(boundary_directory)?;
4445
for name in components.into_iter().flatten() {
4546
match rustix::fs::mkdirat(&directory, name, private_directory_mode()) {
4647
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
4748
Err(error) => return Err(error.into()),
4849
}
49-
directory = rustix::fs::openat(
50-
&directory,
51-
name,
52-
directory_open_flags(),
53-
rustix::fs::Mode::empty(),
54-
)?;
50+
let next = open_reconciliation_descendant(&directory, name)?;
51+
if rustix::fs::fstat(&next)?.st_dev != boundary_device {
52+
return Err(io::Error::new(
53+
io::ErrorKind::PermissionDenied,
54+
"ACME managed directory target crosses a filesystem boundary",
55+
));
56+
}
57+
directory = next;
5558
rustix::fs::fchown(
5659
&directory,
5760
Some(rustix::fs::Uid::from_raw(owner.0)),
@@ -62,6 +65,38 @@ pub(crate) fn reconcile_private_directory_subtree(
6265
Ok(directory)
6366
}
6467

68+
#[cfg(target_os = "linux")]
69+
fn open_reconciliation_descendant(
70+
directory: &rustix::fd::OwnedFd,
71+
name: &std::ffi::OsStr,
72+
) -> io::Result<rustix::fd::OwnedFd> {
73+
rustix::fs::openat2(
74+
directory,
75+
name,
76+
directory_open_flags(),
77+
rustix::fs::Mode::empty(),
78+
rustix::fs::ResolveFlags::BENEATH
79+
| rustix::fs::ResolveFlags::NO_SYMLINKS
80+
| rustix::fs::ResolveFlags::NO_MAGICLINKS
81+
| rustix::fs::ResolveFlags::NO_XDEV,
82+
)
83+
.map_err(Into::into)
84+
}
85+
86+
#[cfg(all(unix, not(target_os = "linux")))]
87+
fn open_reconciliation_descendant(
88+
directory: &rustix::fd::OwnedFd,
89+
name: &std::ffi::OsStr,
90+
) -> io::Result<rustix::fd::OwnedFd> {
91+
rustix::fs::openat(
92+
directory,
93+
name,
94+
directory_open_flags(),
95+
rustix::fs::Mode::empty(),
96+
)
97+
.map_err(Into::into)
98+
}
99+
65100
#[cfg(unix)]
66101
fn walk_directory(path: &Path, create: bool) -> io::Result<rustix::fd::OwnedFd> {
67102
let mut directory = rustix::fs::openat(
@@ -254,4 +289,57 @@ mod tests {
254289
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
255290
assert!(!outside.exists());
256291
}
292+
293+
#[cfg(target_os = "linux")]
294+
#[test]
295+
fn owned_directory_reconciliation_rejects_bind_mount() {
296+
// Run only inside an isolated privileged mount namespace.
297+
const ENABLED: &str = "FLUXHEIM_ACME_MOUNT_BOUNDARY_TEST";
298+
if std::env::var_os(ENABLED).as_deref() != Some(std::ffi::OsStr::new("1")) {
299+
eprintln!("privileged ACME mount-boundary test skipped");
300+
return;
301+
}
302+
if !rustix::process::geteuid().is_root() {
303+
panic!("privileged ACME mount-boundary test requires root");
304+
}
305+
306+
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
307+
308+
struct BindMount(std::path::PathBuf);
309+
impl Drop for BindMount {
310+
fn drop(&mut self) {
311+
let _ = rustix::mount::unmount(&self.0, rustix::mount::UnmountFlags::DETACH);
312+
}
313+
}
314+
315+
let root = fluxheim_common::test_support::unique_temp_path("acme-directory-mount");
316+
let boundary = root.join("storage");
317+
let mount_point = boundary.join("mounted");
318+
let outside = root.join("outside");
319+
std::fs::create_dir_all(&mount_point).unwrap();
320+
std::fs::create_dir_all(&outside).unwrap();
321+
std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o755)).unwrap();
322+
let original = std::fs::symlink_metadata(&outside).unwrap();
323+
rustix::mount::mount_bind(&outside, &mount_point).unwrap();
324+
let _mount = BindMount(mount_point.clone());
325+
326+
let boundary_directory = open_directory_no_symlinks(&boundary).unwrap();
327+
assert!(
328+
reconcile_private_directory_subtree(
329+
&boundary,
330+
&boundary_directory,
331+
&mount_point.join("child"),
332+
(65_534, 65_534),
333+
)
334+
.is_err()
335+
);
336+
337+
let after = std::fs::symlink_metadata(&outside).unwrap();
338+
assert_eq!((after.uid(), after.gid()), (original.uid(), original.gid()));
339+
assert_eq!(
340+
after.permissions().mode() & 0o777,
341+
original.permissions().mode() & 0o777
342+
);
343+
assert!(!outside.join("child").exists());
344+
}
257345
}

docs/certificate-renewal.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ descriptor for the nearest existing storage boundary. Every managed descendant
7575
is reopened relative to that descriptor and reconciled to the selected UID/GID
7676
and mode `0700`, including descendants left `root:root` by an interrupted prior
7777
handoff. Paths outside the recorded boundary are rejected before filesystem
78-
mutation, and ancestors above it are never re-owned.
78+
mutation, and ancestors above it are never re-owned. Linux requires
79+
`openat2(2)` support for root-run reconciliation and uses `RESOLVE_BENEATH`,
80+
`RESOLVE_NO_SYMLINKS`, `RESOLVE_NO_MAGICLINKS`, and `RESOLVE_NO_XDEV`; an older
81+
kernel fails closed rather than falling back. Other Unix platforms compare the
82+
opened boundary and descendant device identifiers before changing ownership or
83+
mode.
7984

8085
```bash
8186
fluxheim --config path/to/fluxheim.toml --check-tls-storage

release-notes/RELEASE_NOTES_1.7.9.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ products.
4747
descriptor and reconciles every managed descendant to the selected UID/GID
4848
through descriptor-relative, no-symlink traversal. Restart repairs
4949
intermediate `0700 root:root` directories left by an interrupted root-run
50-
handoff, while outside-boundary targets fail before mutation.
50+
handoff, while outside-boundary targets fail before mutation. Linux also
51+
enforces `openat2(RESOLVE_NO_XDEV)` and other Unix platforms reject device-ID
52+
changes before ownership mutation, preventing reconciliation through nested
53+
mount points.
5154
- Managed ACME account generation now creates P-256 material in zeroizing
5255
RustCrypto secret/document types before importing it into Ring and retaining
5356
the durable copy in `sanitization::SecretVec`, removing the transient

0 commit comments

Comments
 (0)