Skip to content
Merged
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
50 changes: 40 additions & 10 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,22 +411,52 @@ request fails if its private namespace cannot be configured.
private network namespace. Clients that ignore the env vars (raw sockets,
custom HTTP clients) can no longer reach the network directly: only loopback
and the proxy endpoint are permitted. DNS is deliberately **not** opened —
the proxy resolves on the workload's behalf — so the proxy endpoint must be
an IPv4 literal. IPv6 egress is denied outright.

Consequently the only accepted host-local proxy endpoints are `localhost`,
`127.0.0.0/8` and the wildcards `0.0.0.0` / `::`, each rewritten to
`10.0.2.2`. `::1` is **rejected at validation time**: a proxy bound only to
the IPv6 loopback cannot accept the IPv4 connection slirp's gateway
produces, so translating it would hand the sandbox an address nothing
answers on. Bind such a proxy to `127.0.0.1` or to a dual-stack wildcard
instead.
the proxy resolves on the workload's behalf. IPv6 egress is denied outright.

Host-local proxy endpoints — `localhost`, `127.0.0.0/8` and the wildcards
`0.0.0.0` / `::` — are rewritten to `10.0.2.2`. `::1` is **rejected at
validation time**: a proxy bound only to the IPv6 loopback cannot accept the
IPv4 connection slirp's gateway produces, so translating it would hand the
sandbox an address nothing answers on. Bind such a proxy to `127.0.0.1` or to
a dual-stack wildcard instead.

On schema **0.6/0.7** the legacy behavior applies: the sandbox shares the
host network namespace, no egress rules are installed, and only the
cooperative env-var routing is in effect — a client that ignores
`HTTP_PROXY`/`HTTPS_PROXY` reaches the network directly. For strict
whole-network isolation on those versions, omit `network.proxy` so the
runner can apply `--unshare-net` instead.
- **Hostname proxy endpoints are pinned, not resolved in the sandbox**: because
DNS is closed, a hostname in `network.proxy.url` cannot be resolved by the
workload. The runner resolves it **once on the host** before the sandbox
starts, opens the egress chain for that address, and pins
`<address> <hostname>` as the first line of a generated `/etc/hosts` that is
bind-mounted read-only over the sandbox's copy. The workload therefore sees
the URL exactly as configured, so `Host` headers and proxy-auth realms match.
Consequences worth knowing:
- The name is resolved **once**, at start. A proxy whose address changes
mid-run is not followed.
- Only the pinned address is opened in the egress chain, so a resolver that
bypasses `/etc/hosts` (for example a client that speaks DNS directly, which
is itself blocked) cannot reach a different address. The failure is closed.
- IP **literals** are rewritten rather than pinned, per the rules above. A
hostname that resolves to a loopback address is likewise pinned to the
gateway.
- `localhost` is always rewritten, never pinned. It is reserved to loopback
(RFC 6761) and a pin is a sandbox-wide mapping, so pinning it would
redirect the workload's own loopback traffic to the host.
- The generated `/etc/hosts` preserves the host's existing entries after the
pin line, so `localhost` and friends keep working. A `readwritePaths` or
`readonlyPaths` entry covering `/etc/hosts` is overridden by the pin mount,
with a warning — that narrows the caller's access rather than widening it.
- A **`deniedPaths`** entry covering `/etc/hosts` (directly or via an
ancestor such as `/etc`) is **rejected** instead. The pin is applied after
every policy mount, so honouring it would hand back a readable file
populated from the host's own `/etc/hosts` — the opposite of the requested
denial. Give the proxy an IP address instead, which needs no pin. A more
specific grant beneath the denial (for example denying `/etc` while listing
`/etc/hosts` under `readonlyPaths`) takes effect and is accepted.
- IPv6-only proxy hostnames are rejected, matching the IPv6 egress denial.
- **Mutually exclusive with iptables enforcement**: setting
`network.proxy` together with `network.enforcementMode` of `"firewall"`
or `"both"` is rejected at config-parse time. (The rejection message cites a
Expand Down
15 changes: 15 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ cannot mix both formats in one request.
// The chain hooks FORWARD, so traffic addressed to the
// bridge gateway itself is delivered locally via INPUT
// and is outside what this chain governs.
// Under Bubblewrap on schema 0.8+ the proxy is likewise
// enforced, in the sandbox's own network namespace:
// egress is dropped except the proxy endpoint, and DNS
// is not opened. A url-form hostname is resolved on the
// host and pinned into the sandbox's /etc/hosts, since
// the sandbox has no resolver of its own. `localhost`,
// 127.0.0.0/8 and the wildcards 0.0.0.0 / :: are
// rewritten to the slirp gateway; `::1` is rejected,
// because an IPv6-loopback listener cannot accept the
// IPv4 connection that gateway produces. Because the
// pin outranks every filesystem mount, a `deniedPaths`
// entry covering /etc/hosts is rejected rather than
// silently overridden. On schema
// 0.6/0.7 Bubblewrap keeps the cooperative-only
// behavior (no egress rules).
},

"ui": {
Expand Down
11 changes: 8 additions & 3 deletions src/backends/bubblewrap/common/src/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ use wxc_common::filesystem_resolve::FsIntent;
use wxc_common::models::{ExecutionRequest, NetworkEnforcementMode, NetworkPolicy, ProxyAddress};
use wxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS};

/// The fixed prefix of the command bwrap is asked to run.
///
/// `build_args` appends this last, so its position identifies where the
/// options end -- unlike a bare `--` token, which a caller-supplied
/// environment value can also be. Shared so the two cannot drift apart.
pub(crate) const COMMAND_TAIL: [&str; 3] = ["--", "sh", "-c"];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this is always last and only 1 place uses this I suggest to move the constant to where it is used. That'll show the "Has to be on the tail" requirement more clearly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It has two users in different modules, which is why it's shared rather than local: bwrap_command.rs:388 appends it, and proxy_network.rs:993-994 reads it back to find where the options end (args[sep..sep + COMMAND_TAIL.len()] == COMMAND_TAIL[..]). Moving it to either site would let the writer and the reader drift apart -- the exact failure the shared constant prevents -- so I'd prefer to keep it here.


/// Read-only host paths bind-mounted into every Bubblewrap sandbox as the
/// deny-by-default baseline. Mirrors the seatbelt backend's
/// `SYSTEM_READ_ALLOW` (`src/backends/seatbelt/common/src/profile_builder.rs`):
Expand Down Expand Up @@ -378,9 +385,7 @@ pub(crate) fn build_args_classified_with_mode(
}

// -- Command -----------------------------------------------------------
args.push("--".into());
args.push("sh".into());
args.push("-c".into());
args.extend(COMMAND_TAIL.iter().map(|arg| arg.to_string()));
args.push(request.script_code.clone());

args
Expand Down
207 changes: 169 additions & 38 deletions src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,18 @@ impl SandboxBackend for BubblewrapScriptRunner {
}
}

// Proxy-only networking rewrites the configured endpoint to slirp's
// host gateway and then opens exactly that address in the egress
// chain. Both steps can reject an endpoint (an IPv6 loopback the
// gateway cannot reach, a routable IPv6 literal the IPv4-only rules
// cannot express, a zero port). Running them here means the caller
// learns at policy time instead of after a namespace, a slirp instance
// and a rule set have already been built and torn down.
// Proxy-only networking derives the sandbox-visible endpoint from the
// configured one and then opens exactly that address in the egress
// chain. That step can reject an endpoint outright (an IPv6 loopback
// the gateway cannot reach, a routable or IPv6-only endpoint the
// IPv4-only rules cannot express, a zero port), and those verdicts
// follow from the configured address alone, so they are reported here
// instead of after a proxy has been started.
//
// A hostname is deliberately left to `run`: its verdict needs a lookup,
// and the answer is the pin the sandbox is given, so resolving here
// would either resolve twice or pin an address the egress chain never
// opened.
//
// The builtin test server has no address until it is started (the
// parser leaves a port-0 placeholder), so its endpoint stays on the
Expand All @@ -128,11 +133,15 @@ impl SandboxBackend for BubblewrapScriptRunner {
== ResolvedNetworkMode::ProxyOnly;
if proxy_only && !request.policy.network_proxy.builtin_test_server {
if let Some(address) = request.policy.network_proxy.address.as_ref() {
let egress = proxy_network::sandbox_proxy_address(address)
.and_then(|translated| proxy_network::ProxyEgress::from_address(&translated));
if let Err(error) = egress {
if let Err(error) = proxy_network::SandboxProxy::check_without_resolving(address) {
return Err(ScriptResponse::error(&error));
}
if let Err(error) =
proxy_network::check_hosts_pin_against_policy(address, &request.policy)
{
Comment thread
SohamDas2021 marked this conversation as resolved.
return Err(ScriptResponse::error(&error));
}
// Repeated in `spawn` against the normalized policy.
}
}

Expand Down Expand Up @@ -208,11 +217,38 @@ impl SandboxBackend for BubblewrapScriptRunner {
}
None => request,
};
// The masks are built from the list above, not from the one the caller
// wrote, so the pin conflict has to be judged against it too.
if let Err(msg) = check_pin_against_denied_hosts(request) {
return Err(ScriptResponse::error(&msg));
}
let child = self.spawn_bwrap(request, &plan.files, logger, stdio)?;
Ok(Box::new(BubblewrapSandboxProcess::new(child)))
}
}

/// Reject a hostname proxy pin that would defeat a denied `/etc/hosts`.
///
/// Runs in both `validate` (against the policy as written, so the caller hears
/// about it early) and `spawn` (against the normalized policy, which is what
/// the masks are built from). One is not enough: `resolve_denied_paths`
/// rewrites `/etc/../etc/hosts` -- or any symlinked spelling -- to
/// `/etc/hosts`, which the written form never matches, and the pin is spliced
/// after every policy mount, so it hands back the file the policy masked.
fn check_pin_against_denied_hosts(request: &ExecutionRequest) -> Result<(), String> {
let proxy_only =
ResolvedNetworkMode::from_request(request, request.policy.network_proxy.is_enabled())
== ResolvedNetworkMode::ProxyOnly;
// The builtin test server is never a hostname, so it is never pinned.
if !proxy_only || request.policy.network_proxy.builtin_test_server {
return Ok(());
}
match request.policy.network_proxy.address.as_ref() {
Some(address) => proxy_network::check_hosts_pin_against_policy(address, &request.policy),
None => Ok(()),
}
}

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 @@ -247,10 +283,10 @@ impl BubblewrapScriptRunner {
}

let network_mode = ResolvedNetworkMode::from_request(request, proxy.is_active());
let sandbox_proxy_address = if network_mode == ResolvedNetworkMode::ProxyOnly {
let sandbox_proxy = if network_mode == ResolvedNetworkMode::ProxyOnly {
match proxy.address() {
Some(address) => match proxy_network::sandbox_proxy_address(address) {
Ok(address) => Some(address),
Some(address) => match proxy_network::SandboxProxy::resolve(address) {
Ok(resolved) => Some(resolved),
Err(error) => {
proxy.stop(logger);
return Err(ScriptResponse::error(&error));
Expand All @@ -266,34 +302,24 @@ impl BubblewrapScriptRunner {
} else {
None
};
let proxy_address = sandbox_proxy_address.as_ref().or_else(|| proxy.address());
let proxy_address = sandbox_proxy
.as_ref()
.map(|resolved| resolved.address())
.or_else(|| proxy.address());

let mut proxy_network = if network_mode == ResolvedNetworkMode::ProxyOnly {
let mut proxy_network = match sandbox_proxy.as_ref() {
// The workload dials the sandbox-visible address, so that is what
// the egress rule must open.
let egress = match sandbox_proxy_address
.as_ref()
.ok_or_else(|| {
"Bubblewrap: proxy-only networking requires a resolved proxy address"
.to_string()
})
.and_then(proxy_network::ProxyEgress::from_address)
{
Ok(egress) => egress,
Err(error) => {
proxy.stop(logger);
return Err(ScriptResponse::error(&error));
}
};
match proxy_network::ProxyNetworkNamespace::start(&egress, logger) {
Ok(network) => Some(network),
Err(error) => {
proxy.stop(logger);
return Err(ScriptResponse::error(&error));
Some(resolved) => {
match proxy_network::ProxyNetworkNamespace::start(resolved.egress(), logger) {
Ok(network) => Some(network),
Err(error) => {
proxy.stop(logger);
return Err(ScriptResponse::error(&error));
}
}
}
} else {
None
None => None,
};

// 2. Build the bwrap argument vector. `denied_files` is the file-mask
Expand All @@ -311,7 +337,7 @@ impl BubblewrapScriptRunner {
network_mode,
);
let mut network_startup = match proxy_network.as_ref() {
Some(network) => match network.configure_bwrap(&mut args) {
Some(network) => match network.configure_bwrap(&mut args, logger) {
Ok(startup) => Some(startup),
Err(error) => {
stop_proxy_network(&mut proxy_network, logger);
Expand Down Expand Up @@ -878,7 +904,7 @@ mod tests {

/// 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 -- not after a namespace and rule set were built.
/// refused at policy time -- before a proxy is started.
#[test]
fn validate_rejects_an_ipv6_loopback_proxy_endpoint_before_the_environment_probe() {
let mut req = base_request();
Expand Down Expand Up @@ -973,6 +999,111 @@ mod tests {
);
}

/// A hostname endpoint is reached by pinning it over `/etc/hosts`, and the
/// pin outranks every policy mount -- so a policy that denies the file must
/// refuse the combination rather than silently hand the file back.
#[test]
fn validate_rejects_a_hostname_proxy_that_would_defeat_a_denied_hosts_file() {
let mut req = base_request();
req.schema_version = "0.8.0-alpha".into();
Comment thread
SohamDas2021 marked this conversation as resolved.
req.policy.denied_paths = vec!["/etc/hosts".into()];
req.policy.network_proxy = ProxyConfig {
address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)),
builtin_test_server: false,
};

let runner = BubblewrapScriptRunner::new();
let err = runner.validate(&req).unwrap_err();

assert!(
err.error_message.contains("deniedPaths"),
"the conflict must be refused at policy time: {}",
err.error_message
);
}

/// `validate` compares the denied paths as written, so a spelling that only
/// becomes `/etc/hosts` after normalization slips past it. `spawn` builds
/// the masks from the normalized list, so the mask lands on `/etc/hosts`
/// and the pin -- spliced after every policy mount -- then hands the file
/// back. Regression test: the check must also see the normalized policy.
#[test]
fn a_dotdot_spelling_of_a_denied_hosts_file_still_refuses_the_pin() {
let mut req = base_request();
req.schema_version = "0.8.0-alpha".into();
req.policy.network_proxy = ProxyConfig {
address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)),
builtin_test_server: false,
};

// As written, this matches nothing the check looks for.
req.policy.denied_paths = vec!["/etc/../etc/hosts".into()];
assert!(
check_pin_against_denied_hosts(&req).is_ok(),
"precondition: the written form is not recognized"
);

// Normalized -- what `spawn` actually builds the masks from.
let mut normalized = req.clone();
normalized.policy.denied_paths = vec!["/etc/hosts".into()];
let err = check_pin_against_denied_hosts(&normalized)
.expect_err("the normalized policy must refuse the pin");
assert!(err.contains("deniedPaths"), "{err}");
}

/// The rejection is scoped to endpoints that actually produce a pin. An IP
/// literal is reached without touching `/etc/hosts`, so denying the file
/// stays compatible -- and that is the escape hatch the message offers.
#[test]
fn validate_accepts_an_ip_proxy_endpoint_alongside_a_denied_hosts_file() {
let mut req = base_request();
req.schema_version = "0.8.0-alpha".into();
req.policy.denied_paths = vec!["/etc/hosts".into()];
req.policy.network_proxy = ProxyConfig {
address: Some(ProxyAddress::new("10.1.2.3".into(), 3128)),
builtin_test_server: false,
};

let runner = BubblewrapScriptRunner::new();
let message = runner
.validate(&req)
.err()
.map(|err| err.error_message)
.unwrap_or_default();

assert!(
!message.contains("deniedPaths"),
"an endpoint that needs no pin must not inherit the rejection: {message}"
);
}

/// Legacy proxy mode shares the host's network, never translates the
/// endpoint and never pins a name, so the same policy has to stay
/// acceptable there -- rejecting it would break callers already on 0.6/0.7.
#[test]
fn validate_leaves_a_legacy_schema_hosts_denial_untouched() {
let mut req = base_request();
req.schema_version = "0.7.0-alpha".into();
req.policy.denied_paths = vec!["/etc/hosts".into()];
req.policy.network_proxy = ProxyConfig {
address: Some(ProxyAddress::new("proxy.example.com".into(), 3128)),
builtin_test_server: false,
};

let runner = BubblewrapScriptRunner::new();
let message = runner
.validate(&req)
.err()
.map(|err| err.error_message)
.unwrap_or_default();

assert!(
!message.contains("deniedPaths"),
"legacy proxy mode pins nothing, so it must not inherit the \
private-namespace rejection: {message}"
);
}

#[test]
fn validate_rejects_empty_script_before_environment_probe() {
// Empty script_code is a user-input error and must be surfaced
Expand Down
Loading
Loading