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
72 changes: 64 additions & 8 deletions docs/backends/bwrap/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,17 @@ Common consequences of this default:
`readonlyPaths` if the script depends on it.
- `working_directory` must live under the baseline or a policy path β€” a
`cwd` of `~/project` without a matching `readonlyPaths` entry will fail.
- DNS works on systemd-resolved, NetworkManager, and resolvconf hosts
because the corresponding `/run/...` directories are bound. The common
symlink targets *outside* `/run` are covered too: `/var/run/...`-routed
`/etc/resolv.conf` symlinks resolve via a synthesised `/var/run -> /run`
compat symlink, and WSL's `/mnt/wsl/resolv.conf` is bound directly.
Neither exposes host `/var` or `/mnt` contents. Hosts that point
`/etc/resolv.conf` at some other custom location still need that target
listed in `readonlyPaths`.
- The sandbox can *read* the host's resolver on systemd-resolved,
NetworkManager, and resolvconf hosts because the corresponding `/run/...`
directories are bound. The common symlink targets *outside* `/run` are
covered too: `/var/run/...`-routed `/etc/resolv.conf` symlinks resolve via a
synthesised `/var/run -> /run` compat symlink, and WSL's
`/mnt/wsl/resolv.conf` is bound directly. Neither exposes host `/var` or
`/mnt` contents. Hosts that point `/etc/resolv.conf` at some other custom
location still need that target listed in `readonlyPaths`.

Reading the file is not the same as reaching the nameserver it names β€” see
[Loopback resolvers](#loopback-resolvers).

Files in `/etc` that contain secrets (`/etc/shadow`, `/etc/sudoers`,
`/etc/ssh/ssh_host_*_key`) are mode `0400` / `0640` `root` and remain
Expand Down Expand Up @@ -302,6 +305,59 @@ does not resolve, because the sandbox resolves names itself and a lookup that
disagreed with the one behind the rules would hand the workload an address the
chain never authorized.

### Loopback resolvers

Every mode runs the sandbox in its own network namespace, so a nameserver on
the host's loopback β€” `systemd-resolved`'s stub at `127.0.0.53`, a local
`dnsmasq` at `127.0.0.1` β€” names an address that belongs to the sandbox's own
empty loopback. Binding the host's resolver directories makes the file
readable; it does not make that address reachable, and names do not resolve.

Under **address filtering**, MXC therefore replaces the sandbox's resolver with
slirp's built-in forwarder, `10.0.2.3`. libslirp rewrites a query sent there to
the first IPv4 nameserver in the *host's* `/etc/resolv.conf` and sends it from
the host's network namespace, which is what puts the host's stub back in reach.
`search`, `options`, and every other directive are carried over unchanged.

The replacement applies only when the host's own resolvers are **all** loopback
addresses and **at least one** is IPv4, so it cannot redirect name resolution
that already works, and it never applies when slirp has no IPv4 nameserver to
forward to. The file is mounted over the path the `/etc/resolv.conf` symlink
chain ends at β€” resolved through every component, not just the leaf, because
bwrap cannot create a mount point under an unresolved symlink and aborts the
sandbox when asked to.

**The pin never costs you a sandbox.** It improves on a resolver the sandbox
already cannot reach, so every failure gives up the pin rather than the run:
a resolver path that cannot be followed, one the filesystem policy denies, a
resolver file that exists but cannot be read, and a failure to write the
replacement all leave the sandbox exactly as it would have started without
this feature, each with a warning naming the cause. A host with no
`/etc/resolv.conf` at all is silent β€” there is no loopback stub to rescue.

A policy that names `/etc/resolv.conf` (or the path its symlink chain ends at)
in `readonlyPaths` / `readwritePaths` is supplying its own resolver, and the
sandbox keeps that one. Listing an ancestor such as `/etc` is not treated as a
choice about the resolver.

Two cases are deliberately untouched:

- **`runtimeConfig.networkProxy`** β€” a proxy-only chain opens no port 53 and
the proxy resolves on the workload's behalf.
- **Ruleless `egress.default: "deny"`** β€” the sandbox has no connectivity at
all, so it has nothing to resolve with.

Under `egress.default: "deny"` *with* rules, the chain's terminal verdict still
governs: a query to `10.0.2.3` is dropped unless a rule permits it. MXC warns
at launch when it pins the resolver and the chain admits nothing to
`10.0.2.3:53`, naming the rule to add β€” allow `10.0.2.3/32` on `udp` port 53.
The rule is not added automatically: the chain is the caller's policy.

> ⚠️ slirp's forwarder reaches the host's resolver by design, and the chain's
> `-d 10.0.2.2/32 -j DROP` rule β€” which closes the sandbox's path to host
> loopback services β€” does not cover it. Name resolution is the one host
> loopback service an address-filtered sandbox can reach.

**IPv6 rules are filtered, but IPv6 traffic has nowhere to go.** The two are
separate concerns and only the second is missing. Filtering works: an IPv6 rule
programs `ip6tables`, and the terminal verdict of the unmatched family follows
Expand Down
24 changes: 15 additions & 9 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,20 @@ pub(crate) const COMMAND_TAIL: [&str; 3] = ["--", "sh", "-c"];
/// - We deliberately do NOT bind `/run` wholesale: `/run/user/<uid>`
/// holds the caller's D-Bus session socket, keyring sockets, and
/// ssh-agent socket. We only bind the well-known DNS stub-resolver
/// directories so name resolution still works when `/etc/resolv.conf`
/// directories so the resolver file is *readable* when `/etc/resolv.conf`
/// is a symlink (the default on systemd-resolved hosts).
/// - To keep DNS working when `/etc/resolv.conf` points *outside* those
/// dirs, we also synthesise a `/var/run -> /run` compat symlink (for
/// - To keep that file readable when `/etc/resolv.conf` points *outside*
/// those dirs, we also synthesise a `/var/run -> /run` compat symlink (for
/// `/var/run/...`-routed targets β€” older RHEL/CentOS-era and some
/// container images) and `--ro-bind-try` `/mnt/wsl/resolv.conf` (for
/// WSL). Neither exposes host `/var` or `/mnt` contents β€” only the
/// resolver path itself.
/// - Readable is not reachable. A nameserver on the host's loopback β€” the
/// systemd-resolved stub at `127.0.0.53`, a local `dnsmasq` β€” belongs to
/// the sandbox's own empty loopback once it is in a private network
/// namespace, so these binds alone leave names unresolvable. Reaching such
/// a resolver is what `proxy_network::ResolverPin` does, by pointing the
/// sandbox at slirp's forwarder.
/// - `/etc` is bound whole because cherry-picking files (`passwd`,
/// `nsswitch.conf`, `ssl/`, `ld.so.conf*`, …) is fragile and breaks
/// tools that read other config files. Files with sensitive contents
Expand Down Expand Up @@ -1285,10 +1291,10 @@ mod tests {
}
}

/// DNS stub-resolver dirs must be in the baseline so `/etc/resolv.conf`
/// symlinks resolve when the caller has network access. Emitted via
/// `--ro-bind-try` so hosts without systemd-resolved / NetworkManager /
/// resolvconf still build a valid argument vector.
/// DNS stub-resolver dirs must be in the baseline so an `/etc/resolv.conf`
/// symlink has a target to resolve to. Emitted via `--ro-bind-try` so hosts
/// without systemd-resolved / NetworkManager / resolvconf still build a
/// valid argument vector.
#[test]
fn baseline_includes_dns_stub_resolver_dirs() {
let args = build_args(&base_request(), None);
Expand All @@ -1302,8 +1308,8 @@ mod tests {
.any(|w| w[0] == "--ro-bind-try" && w[1] == path && w[2] == path);
assert!(
found,
"baseline must emit `--ro-bind-try {} {}` so DNS works when \
/etc/resolv.conf is a symlink",
"baseline must emit `--ro-bind-try {} {}` so the sandbox can read \
/etc/resolv.conf when it is a symlink",
path, path
);
}
Expand Down
181 changes: 136 additions & 45 deletions src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ use std::collections::HashSet;
use std::fmt::Write as FmtWrite;
use std::os::fd::AsFd;
use std::os::unix::process::CommandExt;
use std::path::{Component, Path, PathBuf};
use std::path::Path;
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};

use crate::mxc_common::filesystem_symlink::resolve_through_symlinks;
use crate::mxc_common::interruptible_reader::{wrap_pipe, InterruptibleReader, ReadCanceller};
use crate::mxc_common::logger::Logger;
use crate::mxc_common::models::{ExecutionRequest, ScriptResponse};
Expand All @@ -42,6 +43,8 @@ use crate::bwrap_common::{
proxy_network,
};

const DNS_PORT: u16 = 53;

/// Bubblewrap sandbox runner. Uses only shared `ContainerPolicy` fields β€”
/// no backend-specific config struct required.
#[derive(Default)]
Expand Down Expand Up @@ -350,6 +353,32 @@ fn warn_unreachable_v6_targets(plan: &network_rules::EgressPlan, logger: &mut Lo
));
}

/// Warn that the resolver pin cannot work under the installed chain.
///
/// The pin points the sandbox at slirp's forwarder, but the forwarder is a
/// destination like any other: a chain that does not admit it drops the query
/// and names still fail to resolve. The chain is the caller's policy, so this
/// warns rather than opening the port on their behalf.
fn warn_resolver_blocked_by_egress(
plan: &network_rules::EgressPlan,
resolver: Option<&proxy_network::ResolverPin>,
logger: &mut Logger,
) {
if resolver.is_none() || plan.admits_udp(proxy_network::SLIRP_DNS_FORWARDER_IP, DNS_PORT) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (correctness) β€” a UDP-only admission check gives the wrong advice for TCP DNS.

Attribution: introduced_by_change β€” the replacement retains the host's options line, including use-vc, but this newly added diagnostic checks only UDP/53. glibc's resolv.conf(5) specifies that use-vc forces TCP DNS: with only the suggested UDP rule, the warning is suppressed even though the resolver's TCP packets are blocked by a deny-default chain.

Fix: Account for use-vc when evaluating and explaining the required egress rule; check what slirp supports for TCP DNS rather than suggesting that UDP/53 always suffices.

return;
}
logger.warning_line(&format!(
"WARNING: Bubblewrap pointed the sandbox at slirp's DNS forwarder {} because the host \
resolves through a loopback nameserver, but network.egress does not admit UDP port {} \
to it, so queries are dropped and names will not resolve. Add an allow rule for \
'{}/32' on udp port {} if the workload needs to resolve names.",
proxy_network::SLIRP_DNS_FORWARDER_IP,
DNS_PORT,
proxy_network::SLIRP_DNS_FORWARDER_IP,
DNS_PORT
));
}

impl BubblewrapScriptRunner {
/// Set up networking and spawn `bwrap`, returning a [`BwrapChild`] wrapped
/// by the [`SandboxProcess`] handle. With [`StdioMode::Pipes`] the child's
Expand Down Expand Up @@ -398,10 +427,15 @@ impl BubblewrapScriptRunner {
// the egress rule must open.
Some(resolved) => {
let egress = resolved.egress();

// A proxied chain opens no port 53, so the workload resolves
// through the proxy rather than a nameserver of its own.
let resolver = None;
match proxy_network::ProxyNetworkNamespace::start(
&egress.plan(),
&network_rules::IngressPlan::for_policy(&request.policy),
egress.pin(),
resolver,
logger,
request.script_timeout,
) {
Expand All @@ -422,10 +456,13 @@ impl BubblewrapScriptRunner {
Err(error) => return Err(ScriptResponse::error(&error)),
},
};
let resolver = proxy_network::ResolverPin::for_host(&request.policy, logger);
warn_resolver_blocked_by_egress(&plan, resolver.as_ref(), logger);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low (reliability) β€” the egress warning can describe an unapplied resolver pin.

Attribution: introduced_by_change β€” this warning runs before ProxyNetworkNamespace::start calls stage_resolver. If staging fails, the user is told both to allow 10.0.2.3:53 and that the sandbox kept its original loopback resolver; adding that rule cannot fix the latter state.

Fix: Warn about a blocked DNS forwarder only after the replacement was successfully staged and will be included in bwrap's arguments.

match proxy_network::ProxyNetworkNamespace::start(
&plan,
&network_rules::IngressPlan::for_policy(&request.policy),
None,
resolver.as_ref(),
logger,
request.script_timeout,
) {
Expand Down Expand Up @@ -1120,50 +1157,6 @@ fn is_file_mask_target(path: &str) -> bool {
.unwrap_or(false)
}

/// Resolve every symlink in `path` (leaf and ancestors) to a real filesystem
/// path, tolerating trailing components that do not exist yet.
///
/// `std::fs::canonicalize` resolves symlinks at every level but requires the
/// **whole** path to exist. To also cover not-yet-created denied paths under a
/// symlinked ancestor, this walks the components from the root: every existing
/// prefix is canonicalized (following symlinks exactly like the kernel), while
/// `.` and `..` in the not-yet-existent tail are folded lexically. Folding `..`
/// this way is safe because a component that does not exist cannot be a symlink,
/// so the result matches the target the kernel's path resolution would reach.
/// Returns `None` only for an empty path.
///
/// A naive backward walk that collected `file_name()` silently dropped `..`
/// components (Rust returns `None` for a `..` file name) and reconstructed the
/// wrong target: `/link/missing/../secret` became `/real/missing/secret`
/// instead of `/real/secret`, so the mask landed on a bystander path and the
/// real denied target stayed exposed.
fn resolve_through_symlinks(path: &Path) -> Option<PathBuf> {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => result.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
Component::Normal(name) => {
result.push(name);
// Canonicalize the prefix so far so symlinks are followed while
// it still exists; once a component is missing, canonicalize
// fails and the remaining tail is folded lexically above.
if let Ok(real) = std::fs::canonicalize(&result) {
result = real;
}
}
}
}
if result.as_os_str().is_empty() {
None
} else {
Some(result)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1580,6 +1573,104 @@ mod tests {
assert!(logger.warnings().is_empty(), "no v6 allow, no warning");
}

/// A pin built the way production builds one.
fn loopback_resolver_pin(dir: &tempfile::TempDir) -> proxy_network::ResolverPin {
let path = dir.path().join("resolv.conf");
std::fs::write(&path, "nameserver 127.0.0.53\nsearch corp.example\n")
.expect("fixture resolver");
let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer);
proxy_network::ResolverPin::from_resolver(
&path,
&crate::mxc_common::models::ContainerPolicy::default(),
&mut logger,
)
.expect("a loopback stub is pinned")
}

/// An allowlist that never names slirp's forwarder leaves the pinned
/// resolver unreachable, which the sandbox cannot report for itself: the
/// resolver file looks correct and the query is simply dropped.
#[test]
fn a_pinned_resolver_the_chain_drops_is_warned_about() {
use crate::mxc_common::models::{NetworkEgressPolicy, NetworkPeer, NetworkRule};

let mut req = base_request();
req.policy.network_egress = Some(NetworkEgressPolicy {
allow: vec![NetworkRule {
to: vec![NetworkPeer {
cidr: "140.82.112.0/20".parse().expect("test CIDR"),
except: vec![],
}],
ports: vec![],
}],
..Default::default()
});
let plan =
network_rules::EgressPlan::for_request(&req).expect("an allowlist is enforceable");
let dir = tempfile::tempdir().unwrap();
let pin = loopback_resolver_pin(&dir);

let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer);
warn_resolver_blocked_by_egress(&plan, Some(&pin), &mut logger);
let out = logger.warnings().join("\n");
assert!(out.contains("10.0.2.3"), "must name the forwarder: {out}");
assert!(
out.contains("udp") && out.contains("53"),
"must name the rule the caller needs: {out}"
);
assert!(
logger.get_buffer().is_empty(),
"the warning must travel as a retained warning, not as buffer output"
);

// A host whose resolver already works is never pinned, so there is
// nothing to warn about however closed the chain is.
let mut unpinned = Logger::new(crate::mxc_common::logger::Mode::Buffer);
warn_resolver_blocked_by_egress(&plan, None, &mut unpinned);
assert!(
unpinned.warnings().is_empty(),
"no pin, no warning: {:?}",
unpinned.warnings()
);
}

/// Warning at a caller who already opened the forwarder would send them to
/// change a rule that is already correct.
#[test]
fn a_pinned_resolver_the_chain_admits_is_not_warned_about() {
use crate::mxc_common::models::{
NetworkEgressPolicy, NetworkPeer, NetworkPort, NetworkProtocol, NetworkRule,
};

let mut req = base_request();
req.policy.network_egress = Some(NetworkEgressPolicy {
allow: vec![NetworkRule {
to: vec![NetworkPeer {
cidr: "10.0.2.3/32".parse().expect("test CIDR"),
except: vec![],
}],
ports: vec![NetworkPort {
protocol: NetworkProtocol::Udp,
port: Some(53),
end_port: None,
}],
}],
..Default::default()
});
let plan =
network_rules::EgressPlan::for_request(&req).expect("a udp/53 allow is enforceable");
let dir = tempfile::tempdir().unwrap();
let pin = loopback_resolver_pin(&dir);

let mut logger = Logger::new(crate::mxc_common::logger::Mode::Buffer);
warn_resolver_blocked_by_egress(&plan, Some(&pin), &mut logger);
assert!(
logger.warnings().is_empty(),
"the chain admits DNS, so there is nothing to report: {:?}",
logger.warnings()
);
}

/// Proxy-only mode rewrites the endpoint to slirp's gateway and opens
/// exactly that address, so an endpoint neither step can express has to be
/// refused at policy time -- before a proxy is started.
Expand Down
Loading
Loading