Skip to content

Commit c5c6371

Browse files
committed
feat: masked and read-only container paths (OCI-style hardening)
Add `masked_paths` and `readonly_paths` to CreateRequest and apply them in pivot_fs after all mounts are in place -- so freshly-mounted targets like /proc are covered -- but before pivot, while /dev/null is still reachable for masking. A masked file is covered by a bind of /dev/null (reads return EOF, writes are discarded); a masked directory by an empty read-only tmpfs. A read-only path is bind-mounted onto itself and recursively remounted read-only, leaving its contents readable. Targets that do not exist are skipped, so callers can pass a superset that not every rootfs/kernel populates. Builder gains push_masked_path / push_readonly_path. This is the mechanism only; callers supply the path lists. Signed-off-by: Steven Noonan <steven@edera.dev>
1 parent 316c6b2 commit c5c6371

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

src/config.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,24 @@ pub struct CreateRequest {
121121
/// `/proc` will be mounted regardless of whether a mount specification is configured.
122122
pub mounts: Option<Vec<MountSpec>>,
123123

124+
/// Paths inside the container to mask so their contents are inaccessible,
125+
/// applied after all mounts are in place (so freshly-mounted targets like
126+
/// `/proc` are covered) but before pivot. A file target is covered by a
127+
/// bind of `/dev/null`; a directory target is covered by an empty
128+
/// read-only tmpfs. A target that does not exist is skipped, so the set may
129+
/// be a superset not every rootfs/kernel populates. Mirrors the OCI
130+
/// runtime-spec `maskedPaths`.
131+
#[serde(default)]
132+
pub masked_paths: Option<Vec<String>>,
133+
134+
/// Paths inside the container to make read-only while leaving their
135+
/// contents readable, applied alongside `masked_paths`. Each existing
136+
/// target is bind-mounted onto itself and recursively remounted read-only.
137+
/// A target that does not exist is skipped. Mirrors the OCI runtime-spec
138+
/// `readonlyPaths`.
139+
#[serde(default)]
140+
pub readonly_paths: Option<Vec<String>>,
141+
124142
/// An optional set of resource limits.
125143
/// If this set is not provided, no cgroups will be configured.
126144
pub limits: Option<ResourceLimits>,

src/mount.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,95 @@ pub fn mount_setattr_fd(fd: &OwnedFd, recursive: bool, attr: &libc::mount_attr)
110110
mount_setattr(fd.as_raw_fd(), "", flags, attr)
111111
}
112112

113+
/// Join a container-absolute `path` under `rootfs`, avoiding a doubled
114+
/// separator. `path` is treated as rooted at the container's `/`.
115+
fn join_rootfs(rootfs: &str, path: &str) -> String {
116+
format!(
117+
"{}/{}",
118+
rootfs.trim_end_matches('/'),
119+
path.trim_start_matches('/')
120+
)
121+
}
122+
123+
/// Mask a single container path (OCI `maskedPaths` semantics): cover a file
124+
/// target with a bind of `/dev/null` (reads return EOF, writes are discarded)
125+
/// and a directory target with an empty read-only tmpfs. `path` is resolved
126+
/// under `rootfs`. A target that does not exist is skipped -- the default mask
127+
/// set is a superset that not every rootfs/kernel populates.
128+
///
129+
/// Must be called before pivot, while the original `/dev/null` is still
130+
/// reachable at its normal path.
131+
pub fn mask_path(rootfs: &str, path: &str) -> Result<()> {
132+
let target = join_rootfs(rootfs, path);
133+
let meta = match fs::symlink_metadata(&target) {
134+
Ok(m) => m,
135+
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
136+
Err(e) => return Err(anyhow!("stat {target}: {e}")),
137+
};
138+
139+
let spec = if meta.is_dir() {
140+
// Empty read-only tmpfs over the directory (nosuid/nodev/noexec).
141+
MountSpec {
142+
source: Some("tmpfs".to_string()),
143+
target,
144+
fstype: Some("tmpfs".to_string()),
145+
bind: false,
146+
recurse: false,
147+
unshare: false,
148+
safe: true,
149+
create_mountpoint: false,
150+
read_only: true,
151+
data: Some("size=0k".to_string()),
152+
}
153+
} else {
154+
// Bind /dev/null over the file. Not marked read-only or `safe`: the
155+
// mask is the bind to /dev/null itself, and MOUNT_ATTR_NODEV would
156+
// stop it from behaving as the device node it now is.
157+
MountSpec {
158+
source: Some("/dev/null".to_string()),
159+
target,
160+
fstype: None,
161+
bind: true,
162+
recurse: false,
163+
unshare: false,
164+
safe: false,
165+
create_mountpoint: false,
166+
read_only: false,
167+
data: None,
168+
}
169+
};
170+
171+
spec.mount()
172+
}
173+
174+
/// Make a single container path read-only (OCI `readonlyPaths` semantics)
175+
/// while leaving its contents readable: bind the target onto itself and
176+
/// recursively remount read-only. `path` is resolved under `rootfs`; a target
177+
/// that does not exist is skipped.
178+
pub fn make_readonly(rootfs: &str, path: &str) -> Result<()> {
179+
let target = join_rootfs(rootfs, path);
180+
match fs::symlink_metadata(&target) {
181+
Ok(_) => {}
182+
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
183+
Err(e) => return Err(anyhow!("stat {target}: {e}")),
184+
}
185+
186+
let spec = MountSpec {
187+
source: Some(target.clone()),
188+
target,
189+
fstype: Some("none".to_string()),
190+
bind: true,
191+
recurse: true,
192+
unshare: false,
193+
safe: false,
194+
create_mountpoint: false,
195+
read_only: true,
196+
data: None,
197+
};
198+
199+
spec.mount()
200+
}
201+
113202
impl Mountable for MountSpec {
114203
fn seal(&self) -> Result<()> {
115204
let tree = open_tree(
@@ -254,3 +343,17 @@ impl Mountable for MountSpec {
254343
Ok(())
255344
}
256345
}
346+
347+
#[cfg(test)]
348+
mod tests {
349+
use super::join_rootfs;
350+
351+
#[test]
352+
fn join_rootfs_avoids_double_separators() {
353+
assert_eq!(join_rootfs("/run/root", "/proc/kcore"), "/run/root/proc/kcore");
354+
// Trailing slash on rootfs and missing leading slash on path.
355+
assert_eq!(join_rootfs("/run/root/", "proc/sys"), "/run/root/proc/sys");
356+
// rootfs of "/" stays single-separator.
357+
assert_eq!(join_rootfs("/", "/proc/sysrq-trigger"), "/proc/sysrq-trigger");
358+
}
359+
}

src/runner.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,22 @@ impl CreateRequestBuilder {
316316
self
317317
}
318318

319+
pub fn push_masked_path(mut self, path: impl Into<String>) -> CreateRequestBuilder {
320+
self.config
321+
.masked_paths
322+
.get_or_insert_with(Vec::new)
323+
.push(path.into());
324+
self
325+
}
326+
327+
pub fn push_readonly_path(mut self, path: impl Into<String>) -> CreateRequestBuilder {
328+
self.config
329+
.readonly_paths
330+
.get_or_insert_with(Vec::new)
331+
.push(path.into());
332+
self
333+
}
334+
319335
pub fn push_mutation(mut self, spec: Mutation) -> CreateRequestBuilder {
320336
if self.config.mutations.is_none() {
321337
self.config.mutations = vec![].into();

src/wrap.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,23 @@ impl CreateRequest {
477477
}
478478
}
479479

480+
// Apply OCI-style path hardening after every mount is in place (so
481+
// freshly-mounted targets such as /proc are covered) but before pivot,
482+
// while /dev/null is still reachable for masking. Targets are resolved
483+
// under the new rootfs; missing ones are skipped.
484+
if let Some(masked) = &self.masked_paths {
485+
for path in masked {
486+
crate::mount::mask_path(&rootfs, path)
487+
.map_err(|e| anyhow!("failed to mask {path}: {e}"))?;
488+
}
489+
}
490+
if let Some(readonly) = &self.readonly_paths {
491+
for path in readonly {
492+
crate::mount::make_readonly(&rootfs, path)
493+
.map_err(|e| anyhow!("failed to make {path} read-only: {e}"))?;
494+
}
495+
}
496+
480497
newroot
481498
.pivot()
482499
.map_err(|e| anyhow!("failed to pivot to new rootfs: {e}"))?;

0 commit comments

Comments
 (0)