Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions docs/backends/bwrap/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ requiring root privileges or a container runtime.
## Prerequisites

- **Linux** host with kernel 3.8+ (user namespace support)
- **x86-64 or AArch64** host. The filter also preserves x32 and i386
compatibility on x86-64, and AArch32 compatibility on AArch64. Platform
detection reports Bubblewrap as unavailable on architectures for which MXC
cannot generate the required seccomp program.
- **Bubblewrap** installed and on PATH:
```bash
# Debian/Ubuntu
Expand All @@ -37,17 +41,19 @@ requiring root privileges or a container runtime.
```
The deny-by-default baseline (see [How It Works](#how-it-works)) emits its
read-only mounts via `--ro-bind-try` (bwrap 0.3.1+) and the sandbox
environment is built with `--clearenv` (bwrap 0.5.0+), so **bwrap 0.5.0 or
newer** is required. Platform detection probes `bwrap --version` and reports
the backend as unavailable β€” with the detected version β€” when the host is
below that floor. The probe has a 5-second deadline and retains at most 64 KB
from each output stream. On timeout, its process group is terminated with
`SIGKILL` so wrappers and descendants cannot keep the probe alive. Successful
Rust-executor advisory results are cached for the process lifetime; Rust
failures are not cached, and execution validation probes again before launch
so a changed PATH target cannot reuse an advisory result. The Node SDK caches
the complete `getPlatformSupport()` result, including failures, for the module
lifetime; restart the Node process after remediating the host.
environment is built with `--clearenv` (bwrap 0.5.0+). The address-family
filter uses `--seccomp FD`, which predates that release, so **bwrap 0.5.0 or
newer** remains the requirement. Platform detection probes `bwrap --version`
and reports the backend as unavailable β€” with the detected version β€” when
the host is below that floor. The probe has a 5-second deadline and retains
at most 64 KB from each output stream. On timeout, its process group is
terminated with `SIGKILL` so wrappers and descendants cannot keep the probe
alive. Successful Rust-executor advisory results are cached for the process
lifetime; Rust failures are not cached, and execution validation probes again
before launch so a changed PATH target cannot reuse an advisory result. The
Node SDK caches the complete `getPlatformSupport()` result, including
failures, for the module lifetime; restart the Node process after remediating
the host.
- **Network-enabled private-namespace modes (v0.9+):** `slirp4netns` installed
and on PATH, plus iproute2 `ip`, util-linux `unshare` (with `--map-current-user` and
`--keep-caps`), `nsenter`, `iptables`, `ip6tables`, `iptables-restore`, and
Expand Down Expand Up @@ -141,12 +147,37 @@ Bubblewrap creates a namespace-isolated process by:
invisible inside the sandbox.
3. Layering filesystem policy overrides (read-write, read-only, denied paths)
4. Setting up minimal `/dev`, `/proc`, and `/tmp`
5. Clearing the environment and applying only requested variables
6. Executing the command via `sh -c`
5. Installing a seccomp filter that permits `socket()` and `socketpair()` only
for Unix-domain, IPv4, IPv6, and netlink address families, and disables
io_uring
6. Clearing the environment and applying only requested variables
7. Executing the command via `sh -c`

The sandboxed process runs as a child of `bwrap` and dies automatically when
execution completes β€” no container lifecycle management required.

The address-family filter is always active. Attempts to create VSOCK, packet,
or other unlisted socket families fail with `EPERM`. Because io_uring can
create sockets from submission-queue entries that seccomp cannot inspect, the
filter also makes `io_uring_setup`, `io_uring_enter`, and `io_uring_register`
fail with `ENOSYS`; runtimes that probe for io_uring fall back to their
non-io_uring paths. Other system calls are not restricted by this filter.
Filter construction, writing, or descriptor inheritance failures abort the
launch instead of running without the filter.

On x86-64 hosts, native and x32 workloads retain their normal syscall ABI, and
i386 workloads using the direct `socket` and `socketpair` syscalls receive the
same family filtering. The legacy i386 `socketcall` interface passes the socket
arguments through a pointer that seccomp cannot inspect, so its socket-creation
and socket-pair subcalls fail with `EPERM`; its other subcalls remain available.
On AArch64 hosts, native and AArch32 direct socket syscalls receive the same
family filtering.

MXC supplies one generated program through the legacy `--seccomp FD` option.
Bubblewrap rejects combining that option with `--add-seccomp-fd`, so any future
Bubblewrap syscall policy must be composed into this generated program rather
than passed as a second filter descriptor.

### Deny-by-default filesystem

The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture:
Expand Down
17 changes: 15 additions & 2 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
use std::os::fd::AsFd;
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
Expand All @@ -40,7 +40,7 @@ use crate::bwrap_common::{
bwrap_command::{self, ResolvedNetworkMode},
bwrap_version, network_rules,
provider_monitor::ProviderMonitor,
proxy_network,
proxy_network, seccomp_filter,
};

const DNS_PORT: u16 = 53;
Expand Down Expand Up @@ -529,6 +529,17 @@ impl BubblewrapScriptRunner {
denied_files,
network_mode,
);
let seccomp_filter = match seccomp_filter::create_file() {
Ok(file) => file,
Err(error) => {
stop_proxy_network(&mut proxy_network, logger);
return Err(ScriptResponse::error(&error));
}
};
if let Err(error) = seccomp_filter::add_bwrap_args(&mut args, seccomp_filter.as_raw_fd()) {
stop_proxy_network(&mut proxy_network, logger);
return Err(ScriptResponse::error(&error));
}
let mut network_startup = match proxy_network.as_ref() {
Some(network) => match network.configure_bwrap(&mut args, logger) {
Ok(startup) => Some(startup),
Expand Down Expand Up @@ -595,6 +606,7 @@ impl BubblewrapScriptRunner {
if let Some(startup) = network_startup.as_ref() {
startup.prepare_command(&mut command);
}
proxy_network::inherit_descriptors(&mut command, vec![seccomp_filter.as_raw_fd()]);

let mut child = match command.spawn() {
Ok(process) => process,
Expand Down Expand Up @@ -2110,6 +2122,7 @@ mod tests {
fn validate_surfaces_every_environment_probe_failure() {
let request = base_request();
let failures = [
bwrap_version::BwrapUnavailable::UnsupportedArchitecture("riscv64".to_string()),
bwrap_version::BwrapUnavailable::NotFound,
bwrap_version::BwrapUnavailable::ProbeFailed {
status: Some(126),
Expand Down
29 changes: 29 additions & 0 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use crate::bwrap_common::probe_exec::{self, CapturedOutput};
/// | Flag | First `bwrap` release |
/// |--------------|-----------------------|
/// | `--bind`, `--ro-bind`, `--dev`, `--proc`, `--tmpfs`, `--symlink`, `--chdir`, `--setenv`, `--unshare-*` | 0.1.0 |
/// | `--seccomp` (address-family filter descriptor) | 0.2.1 |
/// | `--ro-bind-try` (deny-by-default baseline mounts) | 0.3.1 |
/// | `--die-with-parent` (descendants die with the sandbox) | 0.4.0 |
/// | `--clearenv` (minimal sandbox environment) | **0.5.0** |
Expand Down Expand Up @@ -153,6 +154,8 @@ impl fmt::Display for BwrapVersion {
/// `validate` and the engine's platform probe) share one message source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BwrapUnavailable {
/// This build cannot generate a seccomp program for the host architecture.
UnsupportedArchitecture(String),
/// No `bwrap` executable was found on `PATH`.
NotFound,
/// The `bwrap --version` probe failed to start or did not complete
Expand All @@ -176,6 +179,11 @@ pub enum BwrapUnavailable {
impl fmt::Display for BwrapUnavailable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedArchitecture(architecture) => write!(
f,
"Bubblewrap is unavailable on {architecture}: MXC cannot generate its required \
seccomp address-family filter for this architecture."
),
Self::NotFound => write!(
f,
"Bubblewrap (bwrap) is not installed or not on PATH. \
Expand Down Expand Up @@ -225,6 +233,7 @@ impl std::error::Error for BwrapUnavailable {}
/// Runs `bwrap --version` and validates the reported version against
/// [`MIN_BWRAP_VERSION`]. Successful advisory probes are cached.
pub fn probe_bwrap() -> Result<BwrapVersion, BwrapUnavailable> {
ensure_filter_architecture()?;
probe_bwrap_cached(
&CACHED_BWRAP_VERSION,
cached_probe_gate(),
Expand Down Expand Up @@ -265,12 +274,25 @@ where
/// approve a different executable after `PATH` or its contents change.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn probe_bwrap_uncached() -> Result<BwrapVersion, BwrapUnavailable> {
ensure_filter_architecture()?;
probe_bwrap_uncached_until(
Instant::now() + BWRAP_VERSION_TIMEOUT,
BWRAP_VERSION_TIMEOUT,
)
}

#[cfg(target_os = "linux")]
fn ensure_filter_architecture() -> Result<(), BwrapUnavailable> {
crate::bwrap_common::seccomp_filter::unsupported_architecture(std::env::consts::ARCH)
.map(BwrapUnavailable::UnsupportedArchitecture)
.map_or(Ok(()), Err)
}

#[cfg(not(target_os = "linux"))]
fn ensure_filter_architecture() -> Result<(), BwrapUnavailable> {
Ok(())
}

fn probe_bwrap_uncached_until(
deadline: Instant,
timeout: Duration,
Expand Down Expand Up @@ -724,6 +746,13 @@ mod tests {
}
}

#[test]
fn unsupported_architecture_message_names_the_filter_requirement() {
let message = BwrapUnavailable::UnsupportedArchitecture("riscv64".to_string()).to_string();
assert!(message.contains("riscv64"));
assert!(message.contains("seccomp address-family filter"));
}

#[test]
fn probe_failure_message_preserves_status_and_stderr() {
// A broken-but-present bwrap must not be reported as "not installed":
Expand Down
2 changes: 2 additions & 0 deletions src/mxc-sdk/src/backends/bubblewrap/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ pub(crate) mod provider_monitor;
/// crate; every other item stays `pub(crate)`.
#[cfg(target_os = "linux")]
pub mod proxy_network;
#[cfg(any(target_os = "linux", test))]
pub(crate) mod seccomp_filter;
Original file line number Diff line number Diff line change
Expand Up @@ -2327,7 +2327,7 @@ fn wait_for_file(
/// receives it in argv (`--info-fd N`) and parses it as an integer. A child
/// that must *redirect* to the descriptor from a shell needs
/// [`remap_descriptors`] instead.
fn inherit_descriptors(command: &mut Command, fds: Vec<RawFd>) {
pub(crate) fn inherit_descriptors(command: &mut Command, fds: Vec<RawFd>) {
// SAFETY: `pre_exec` runs between fork and exec, where only
// async-signal-safe work is permitted. `fcntl` is async-signal-safe and
// this closure allocates nothing -- `fds` is captured by move and holds
Expand Down
Loading
Loading