Skip to content

Commit b351fbd

Browse files
authored
fix(sandbox): skip sysfs tmpfs overlays on Podman (#765)
1 parent fafc81f commit b351fbd

3 files changed

Lines changed: 78 additions & 22 deletions

File tree

crates/tools/src/sandbox/docker.rs

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,23 @@ use {
3333
},
3434
};
3535

36+
/// Distinguishes Docker from Podman for behaviour that differs between the two
37+
/// OCI runtimes (hardening flags, host-gateway resolution, etc.).
38+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39+
pub(crate) enum BackendKind {
40+
Docker,
41+
Podman,
42+
}
43+
3644
/// Docker/Podman-based sandbox implementation.
3745
///
3846
/// The `cli` field selects the container CLI binary (`"docker"` or `"podman"`).
3947
/// Podman's CLI is a drop-in replacement for Docker, so both backends share
40-
/// this single implementation.
48+
/// this single implementation. `kind` carries the typed backend identity for
49+
/// behaviour branching without string comparisons.
4150
pub struct DockerSandbox {
4251
pub config: SandboxConfig,
52+
kind: BackendKind,
4353
cli: &'static str,
4454
backend_label: &'static str,
4555
}
@@ -48,6 +58,7 @@ impl DockerSandbox {
4858
pub fn new(config: SandboxConfig) -> Self {
4959
Self {
5060
config,
61+
kind: BackendKind::Docker,
5162
cli: "docker",
5263
backend_label: "docker",
5364
}
@@ -56,6 +67,7 @@ impl DockerSandbox {
5667
pub fn podman(config: SandboxConfig) -> Self {
5768
Self {
5869
config,
70+
kind: BackendKind::Podman,
5971
cli: "podman",
6072
backend_label: "podman",
6173
}
@@ -138,7 +150,7 @@ impl DockerSandbox {
138150
/// Podman uses a bridge whose gateway we can query via
139151
/// `podman network inspect`.
140152
pub(crate) fn resolve_host_gateway(&self) -> String {
141-
if self.cli != "podman" {
153+
if self.kind != BackendKind::Podman {
142154
return "host-gateway".to_string();
143155
}
144156

@@ -179,7 +191,7 @@ impl DockerSandbox {
179191
/// `is_prebuilt` controls whether `--read-only` is applied: prebuilt images
180192
/// already have packages baked in so the root FS can be read-only, while
181193
/// non-prebuilt images need a writable root for `apt-get` provisioning.
182-
pub(crate) fn hardening_args(is_prebuilt: bool) -> Vec<String> {
194+
pub(crate) fn hardening_args(is_prebuilt: bool, kind: BackendKind) -> Vec<String> {
183195
let mut args = vec![
184196
// --- Capability / privilege ---
185197
"--cap-drop".to_string(),
@@ -196,19 +208,28 @@ impl DockerSandbox {
196208
// and the `hostname` command do not reveal the host identity.
197209
"--hostname".to_string(),
198210
"sandbox".to_string(),
199-
// Mask /sys subtrees that expose host hardware identifiers
200-
// (serial numbers, BIOS/UEFI data, disk models, LUKS UUIDs).
201-
// Empty read-only tmpfs overlays hide the underlying sysfs entries
202-
// and work identically on Docker and Podman.
203-
"--tmpfs".to_string(),
204-
"/sys/firmware:ro,nosuid".to_string(),
205-
"--tmpfs".to_string(),
206-
"/sys/class/dmi:ro,nosuid".to_string(),
207-
"--tmpfs".to_string(),
208-
"/sys/devices/virtual/dmi:ro,nosuid".to_string(),
209-
"--tmpfs".to_string(),
210-
"/sys/class/block:ro,nosuid".to_string(),
211211
];
212+
// Mask /sys subtrees that expose host hardware identifiers
213+
// (serial numbers, BIOS/UEFI data, disk models, LUKS UUIDs).
214+
// Empty read-only tmpfs overlays hide the underlying sysfs entries.
215+
//
216+
// Podman is excluded: its OCI runtime performs "tmpcopyup" on sysfs
217+
// tmpfs mounts, copying directory contents into the tmpfs first.
218+
// With --cap-drop ALL some sysfs files are permission-denied even for
219+
// root, causing the mount (and container startup) to fail. Podman
220+
// already masks /sys/firmware via its built-in OCI MaskedPaths.
221+
if kind != BackendKind::Podman {
222+
args.extend([
223+
"--tmpfs".to_string(),
224+
"/sys/firmware:ro,nosuid".to_string(),
225+
"--tmpfs".to_string(),
226+
"/sys/class/dmi:ro,nosuid".to_string(),
227+
"--tmpfs".to_string(),
228+
"/sys/devices/virtual/dmi:ro,nosuid".to_string(),
229+
"--tmpfs".to_string(),
230+
"/sys/class/block:ro,nosuid".to_string(),
231+
]);
232+
}
212233
if is_prebuilt {
213234
args.push("--read-only".to_string());
214235
}
@@ -321,7 +342,7 @@ impl Sandbox for DockerSandbox {
321342
}
322343

323344
args.extend(self.resource_args());
324-
args.extend(Self::hardening_args(is_prebuilt));
345+
args.extend(Self::hardening_args(is_prebuilt, self.kind));
325346
args.extend(self.workspace_args());
326347
args.extend(self.home_persistence_args(id)?);
327348

crates/tools/src/sandbox/tests/core.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ fn test_sandbox_scope_display() {
1717

1818
#[test]
1919
fn test_docker_hardening_args_prebuilt() {
20-
let args = DockerSandbox::hardening_args(true);
20+
let args = DockerSandbox::hardening_args(true, BackendKind::Docker);
2121
assert!(args.contains(&"--cap-drop".to_string()));
2222
assert!(args.contains(&"ALL".to_string()));
2323
assert!(args.contains(&"--security-opt".to_string()));
@@ -44,7 +44,7 @@ fn test_docker_hardening_args_prebuilt() {
4444

4545
#[test]
4646
fn test_docker_hardening_args_not_prebuilt() {
47-
let args = DockerSandbox::hardening_args(false);
47+
let args = DockerSandbox::hardening_args(false, BackendKind::Docker);
4848
assert!(args.contains(&"--cap-drop".to_string()));
4949
assert!(args.contains(&"ALL".to_string()));
5050
assert!(args.contains(&"--security-opt".to_string()));
@@ -69,6 +69,34 @@ fn test_docker_hardening_args_not_prebuilt() {
6969
assert!(args.contains(&"/sys/class/block:ro,nosuid".to_string()));
7070
}
7171

72+
#[test]
73+
fn test_docker_hardening_args_podman() {
74+
let args = DockerSandbox::hardening_args(true, BackendKind::Podman);
75+
// Core hardening flags must still be present
76+
assert!(args.contains(&"--cap-drop".to_string()));
77+
assert!(args.contains(&"ALL".to_string()));
78+
assert!(args.contains(&"--security-opt".to_string()));
79+
assert!(args.contains(&"no-new-privileges".to_string()));
80+
assert!(args.contains(&"--read-only".to_string()));
81+
assert!(args.contains(&"/tmp:rw,nosuid,size=256m".to_string()));
82+
assert!(args.contains(&"/run:rw,nosuid,size=64m".to_string()));
83+
let hostname_pos = args
84+
.iter()
85+
.position(|a| a == "--hostname")
86+
.expect("--hostname flag missing");
87+
assert_eq!(
88+
args[hostname_pos + 1],
89+
"sandbox",
90+
"--hostname value should be 'sandbox'"
91+
);
92+
// Sysfs tmpfs overlays must NOT be present — Podman's tmpcopyup breaks
93+
// these under --cap-drop ALL.
94+
assert!(!args.contains(&"/sys/firmware:ro,nosuid".to_string()));
95+
assert!(!args.contains(&"/sys/class/dmi:ro,nosuid".to_string()));
96+
assert!(!args.contains(&"/sys/devices/virtual/dmi:ro,nosuid".to_string()));
97+
assert!(!args.contains(&"/sys/class/block:ro,nosuid".to_string()));
98+
}
99+
72100
#[test]
73101
fn test_workspace_mount_display() {
74102
assert_eq!(WorkspaceMount::None.to_string(), "none");

docs/src/sandbox.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,10 @@ hardening flags by default:
120120
| `--tmpfs /run:rw,nosuid,size=64m` | Writable tmpfs for runtime files |
121121
| `--read-only` | Read-only root filesystem (prebuilt images only) |
122122
| `--hostname sandbox` | Prevents host hostname leakage |
123-
| `--tmpfs /sys/firmware:ro,nosuid` | Masks BIOS/UEFI firmware data |
124-
| `--tmpfs /sys/class/dmi:ro,nosuid` | Masks system serial numbers and identifiers |
125-
| `--tmpfs /sys/devices/virtual/dmi:ro,nosuid` | Masks DMI attributes |
126-
| `--tmpfs /sys/class/block:ro,nosuid` | Masks block device info (disk models, LUKS UUIDs) |
123+
| `--tmpfs /sys/firmware:ro,nosuid` | Masks BIOS/UEFI firmware data (Docker only) |
124+
| `--tmpfs /sys/class/dmi:ro,nosuid` | Masks system serial numbers and identifiers (Docker only) |
125+
| `--tmpfs /sys/devices/virtual/dmi:ro,nosuid` | Masks DMI attributes (Docker only) |
126+
| `--tmpfs /sys/class/block:ro,nosuid` | Masks block device info (Docker only) |
127127

128128
The `--read-only` flag is applied only to prebuilt sandbox images (where
129129
packages are already baked in). Non-prebuilt images need a writable root
@@ -134,6 +134,13 @@ models, LUKS UUIDs) from being visible inside the container. Note that
134134
`tools.fs.deny_paths` only restricts Moltis file-access tools — these kernel
135135
filesystem masks prevent leakage via shell commands as well.
136136

137+
> **Podman note:** The sysfs tmpfs overlays are applied on Docker only. Podman's
138+
> OCI runtime performs "tmpcopyup" when mounting tmpfs over sysfs paths, which
139+
> fails under `--cap-drop ALL` because some sysfs files are permission-denied
140+
> even for root. Podman masks `/sys/firmware` via its built-in OCI
141+
> `MaskedPaths`; `/sys/class/dmi`, `/sys/devices/virtual/dmi`, and
142+
> `/sys/class/block` remain readable inside the container on Podman.
143+
137144
## WASM Sandbox (Wasmtime + WASI)
138145

139146
The WASM sandbox provides real sandboxed execution using

0 commit comments

Comments
 (0)