Skip to content

Commit b71afdd

Browse files
authored
Merge pull request ultraworkers#3280 from Einspanner123/fix/sandbox-map-auto-fallback
fix(sandbox): fall back to --map-auto when root-user mapping is restricted
2 parents 4ea31c1 + 525035b commit b71afdd

1 file changed

Lines changed: 131 additions & 16 deletions

File tree

rust/crates/runtime/src/sandbox.rs

Lines changed: 131 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,14 @@ pub fn build_linux_sandbox_command(
220220
return None;
221221
}
222222

223-
let mut args = vec![
224-
"--user".to_string(),
225-
"--map-root-user".to_string(),
226-
"--mount".to_string(),
227-
"--ipc".to_string(),
228-
"--pid".to_string(),
229-
"--uts".to_string(),
230-
"--fork".to_string(),
231-
];
223+
let mut args: Vec<String> = working_unshare_mapping()
224+
.unwrap_or(UNSHARE_MAPPING_CANDIDATES[0])
225+
.iter()
226+
.map(|arg| arg.to_string())
227+
.collect();
228+
// The candidates already carry the namespace flags, so the probe and the
229+
// launcher share a single argument shape; only the opt-in `--net` is
230+
// added here.
232231
if status.network_active {
233232
args.push("--net".to_string());
234233
}
@@ -282,6 +281,82 @@ fn command_exists(command: &str) -> bool {
282281
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
283282
}
284283

284+
/// Candidate `unshare` user-namespace mapping options, in preference order.
285+
///
286+
/// Most systems accept `--map-root-user` alone. Some hardened containers and
287+
/// seccomp profiles block unprivileged writes to `/proc/self/uid_map`; there,
288+
/// util-linux delegates to the setuid `newuidmap`/`newgidmap` helpers when
289+
/// `--map-auto` is also present.
290+
///
291+
/// That fallback therefore depends on the setuid helpers (the `uidmap`
292+
/// package on Debian/Ubuntu) and on the current user having a range in
293+
/// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto`
294+
/// fails and the startup probe rejects the candidate, keeping the plain form.
295+
///
296+
/// Each candidate is the **complete** static argument shape the launcher
297+
/// uses (see `build_linux_sandbox_command`): mapping flags followed by the
298+
/// namespace flags `--mount --ipc --pid --uts --fork`. The startup probe
299+
/// runs each candidate verbatim (plus a trivial program), so probe success
300+
/// implies launch success: on systems where the mapping works but the
301+
/// namespace flags are denied (e.g. AppArmor-restricted CI runners that
302+
/// block mount propagation in user namespaces), the probe fails and the
303+
/// sandbox stays disabled instead of activating a launcher that always
304+
/// errors.
305+
///
306+
/// `--net` is intentionally absent: it is appended only when network
307+
/// isolation is active (the non-default path), and probing with it would
308+
/// disable the sandbox on hosts that block network-namespace creation (e.g.
309+
/// Docker's default seccomp profile) even when network isolation is never
310+
/// requested.
311+
const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[
312+
&[
313+
"--user",
314+
"--map-root-user",
315+
"--mount",
316+
"--ipc",
317+
"--pid",
318+
"--uts",
319+
"--fork",
320+
],
321+
&[
322+
"--user",
323+
"--map-root-user",
324+
"--map-auto",
325+
"--mount",
326+
"--ipc",
327+
"--pid",
328+
"--uts",
329+
"--fork",
330+
],
331+
];
332+
333+
/// Probe a candidate `unshare` mapping invocation with a trivial program.
334+
fn unshare_probe(args: &[&str]) -> bool {
335+
std::process::Command::new("unshare")
336+
.args(args)
337+
.arg("true")
338+
.stdin(std::process::Stdio::null())
339+
.stdout(std::process::Stdio::null())
340+
.stderr(std::process::Stdio::null())
341+
.status()
342+
.is_ok_and(|status| status.success())
343+
}
344+
345+
/// The first mapping option set that works on this machine, if any.
346+
///
347+
/// Probes are cached for the process lifetime; a missing `unshare` binary or a
348+
/// kernel that refuses every mapping yields `None`.
349+
fn working_unshare_mapping() -> Option<&'static [&'static str]> {
350+
use std::sync::OnceLock;
351+
static MAPPING: OnceLock<Option<&'static [&'static str]>> = OnceLock::new();
352+
*MAPPING.get_or_init(|| {
353+
UNSHARE_MAPPING_CANDIDATES
354+
.iter()
355+
.copied()
356+
.find(|args| unshare_probe(args))
357+
})
358+
}
359+
285360
/// Check whether `unshare --user` actually works on this system.
286361
/// On some CI environments (e.g. GitHub Actions), the binary exists but
287362
/// user namespaces are restricted, causing silent failures.
@@ -292,13 +367,7 @@ fn unshare_user_namespace_works() -> bool {
292367
if !command_exists("unshare") {
293368
return false;
294369
}
295-
std::process::Command::new("unshare")
296-
.args(["--user", "--map-root-user", "true"])
297-
.stdin(std::process::Stdio::null())
298-
.stdout(std::process::Stdio::null())
299-
.stderr(std::process::Stdio::null())
300-
.status()
301-
.is_ok_and(|status| status.success())
370+
working_unshare_mapping().is_some()
302371
})
303372
}
304373

@@ -359,6 +428,52 @@ mod tests {
359428
assert_eq!(request.allowed_mounts, vec!["tmp"]);
360429
}
361430

431+
#[test]
432+
fn mapping_candidates_prefer_plain_root_mapping() {
433+
assert!(!super::UNSHARE_MAPPING_CANDIDATES.is_empty());
434+
for candidate in super::UNSHARE_MAPPING_CANDIDATES {
435+
// Mapping flags.
436+
assert!(candidate.contains(&"--user"));
437+
assert!(candidate.contains(&"--map-root-user"));
438+
// Namespace flags the real launcher appends — the probe must
439+
// exercise the full invocation shape, not just mapping flags.
440+
assert!(candidate.contains(&"--mount"));
441+
assert!(candidate.contains(&"--ipc"));
442+
assert!(candidate.contains(&"--pid"));
443+
assert!(candidate.contains(&"--uts"));
444+
assert!(candidate.contains(&"--fork"));
445+
}
446+
// The plain form must be tried first; `--map-auto` is only a fallback
447+
// for kernels/containers that block unprivileged uid_map writes.
448+
assert_eq!(
449+
super::UNSHARE_MAPPING_CANDIDATES[0],
450+
&[
451+
"--user",
452+
"--map-root-user",
453+
"--mount",
454+
"--ipc",
455+
"--pid",
456+
"--uts",
457+
"--fork",
458+
]
459+
);
460+
// The second candidate inserts `--map-auto` in the position util-linux
461+
// expects (after `--map-root-user`, before the namespace flags).
462+
assert_eq!(
463+
super::UNSHARE_MAPPING_CANDIDATES[1],
464+
&[
465+
"--user",
466+
"--map-root-user",
467+
"--map-auto",
468+
"--mount",
469+
"--ipc",
470+
"--pid",
471+
"--uts",
472+
"--fork",
473+
]
474+
);
475+
}
476+
362477
#[test]
363478
fn builds_linux_launcher_with_network_flag_when_requested() {
364479
let config = SandboxConfig::default();

0 commit comments

Comments
 (0)