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
33 changes: 29 additions & 4 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,11 @@ Example:

Bubblewrap supports two network modes:

**Full block** (`defaultPolicy: "block"`, no host lists) β€” uses
`--unshare-net` for complete network namespace isolation. No network stack
is available inside the sandbox (including loopback). Runs fully
unprivileged.
**Full block** (`defaultPolicy: "block"`, no host lists, no `network.proxy`)
β€” uses `--unshare-net` for complete network namespace isolation. The sandbox
gets a private network stack with only its own loopback (bwrap brings `lo`
up), so nothing outside the sandbox is reachable and nothing outside can
reach in. Runs fully unprivileged.

```json
{
Expand Down Expand Up @@ -201,6 +202,30 @@ iptables.
**Full allow** (`defaultPolicy: "allow"`, no host lists) β€” the sandbox
shares the host network namespace with no restrictions.

#### `allowLocalNetwork` is not independently enforceable

`network.allowLocalNetwork` controls whether the sandboxed process may
`bind()`/`listen()` on local IPs and accept **inbound** connections. It says
nothing about *outbound* reachability of loopback or RFC1918 addresses β€”
that is governed by `defaultPolicy` / `allowedHosts` / `blockedHosts`.

Bubblewrap has no inbound-only primitive. Unprivileged bwrap has no veth
interface to scope iptables to, and seccomp cannot dereference the `sockaddr`
passed to `bind()`, so an AF_INET-only filter is not expressible. The
namespace choice alone decides the outcome:

| `allowLocalNetwork` | Namespace | Result |
|---------------------|-----------|--------|
| `false` (default) | private (`--unshare-net`) | Honored at the sandbox boundary β€” nothing outside can reach in. `bind()`/`listen()` still succeed on the sandbox's own loopback, so its processes can talk to each other; that is already inside the caller's trust boundary |
| `false` | shared with host | **Not honored** β€” the process can bind/listen on host-local addresses |
| `true` | private (`--unshare-net`) | **Partially honored** β€” the listener is reachable only from inside the sandbox |
| `true` | shared with host | Honored |

Rows 2 and 3 emit a `WARNING:` line to the runner log at preflight rather
than failing silently. Windows (AppContainer's `privateNetworkClientServer`
capability) and macOS (Seatbelt's `(allow network-inbound (local ip))`)
enforce the field at the syscall level; this divergence is Linux-specific.

### Process Settings

Standard `process` fields work as expected:
Expand Down
114 changes: 108 additions & 6 deletions src/backends/bubblewrap/common/src/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,63 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
"/mnt/wsl/resolv.conf",
];

/// Whether the sandbox gets its own network namespace (`--unshare-net`) rather
/// than sharing the host's.
///
/// Full isolation applies only when the default policy denies outbound, no
/// per-host rules need iptables on the shared namespace, and no loopback proxy
/// has to stay reachable.
fn uses_private_netns(request: &ExecutionRequest, proxy_address: Option<&ProxyAddress>) -> bool {
request.policy.default_network_policy == NetworkPolicy::Block
&& request.policy.allowed_hosts.is_empty()
&& request.policy.blocked_hosts.is_empty()
&& proxy_address.is_none()
}

/// Describe a `network.allowLocalNetwork` setting Bubblewrap cannot honor, or
/// `None` when the sandbox's namespace already matches the request.
///
/// `allowLocalNetwork` governs whether the sandboxed process may bind/listen on
/// local IPs and accept **inbound** connections (it says nothing about outbound
/// reachability of local addresses β€” that is `defaultPolicy`/`allowedHosts`).
/// Bubblewrap has no inbound-only primitive: the sandbox either gets a private
/// network namespace or shares the host's, and neither can be narrowed further.
/// Unprivileged bwrap has no veth to scope iptables to, and seccomp cannot
/// dereference the `sockaddr` passed to `bind`, so an AF_INET-only filter is not
/// expressible. The namespace choice therefore decides the outcome, and this
/// returns the mismatch so the runner can say so out loud rather than dropping
/// the field silently.
///
/// The private-namespace arm satisfies `false` only at the sandbox boundary:
/// bwrap brings `lo` up inside the new namespace, so sandbox processes can still
/// bind and connect to each other over their own loopback. That stays inside the
/// caller's trust boundary β€” those processes already share pipes, files and the
/// mount namespace β€” so it is not warned about.
pub fn local_network_diagnostic(
request: &ExecutionRequest,
proxy_address: Option<&ProxyAddress>,
) -> Option<&'static str> {
match (
request.policy.allow_local_network,
uses_private_netns(request, proxy_address),
) {
(false, false) => Some(
"WARNING: Bubblewrap: network.allowLocalNetwork=false is not enforced while the \
sandbox shares the host network namespace (defaultPolicy='allow' or network.proxy). \
The sandboxed process can still bind, listen and accept on host-local addresses. For \
an unreachable sandbox use defaultPolicy='block' with no proxy, which applies \
--unshare-net.",
),
(true, true) => Some(
"WARNING: Bubblewrap: network.allowLocalNetwork=true is confined to the sandbox's own \
network namespace. defaultPolicy='block' with no proxy applies --unshare-net, so a \
listener inside the sandbox is reachable only from within it, never from the host. \
Use defaultPolicy='allow' to share the host network namespace.",
),
_ => None,
}
}

/// Build the complete `bwrap` argument list, masking **every** denied path as a
/// directory (`--tmpfs`).
///
Expand Down Expand Up @@ -154,12 +211,7 @@ pub fn build_args_classified(
// applies iptables rules separately. When a network proxy is active we
// also keep the host network namespace so the sandbox can reach the
// loopback proxy.
let has_host_rules =
!request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty();
let full_block = request.policy.default_network_policy == NetworkPolicy::Block
&& !has_host_rules
&& proxy_address.is_none();
if full_block {
if uses_private_netns(request, proxy_address) {
args.push("--unshare-net".into());
}

Expand Down Expand Up @@ -325,6 +377,56 @@ mod tests {
);
}

// ------- allowLocalNetwork diagnostic tests -------------------------

#[test]
fn local_network_denied_under_private_netns_is_not_warned() {
// Default policy (block, no host lists, no proxy) applies
// --unshare-net: nothing outside can reach in, so allowLocalNetwork=false
// is satisfied at the sandbox boundary and needs no warning.
let r = base_request();
assert!(!r.policy.allow_local_network);
assert!(local_network_diagnostic(&r, None).is_none());
}

#[test]
fn local_network_denied_on_shared_netns_warns() {
let mut r = base_request();
r.policy.default_network_policy = NetworkPolicy::Allow;
let msg = local_network_diagnostic(&r, None).expect("shared netns cannot honor the deny");
assert!(msg.contains("allowLocalNetwork=false"));
}

#[test]
fn local_network_denied_with_host_rules_warns() {
let mut r = base_request();
r.policy.blocked_hosts = vec!["evil.example.com".into()];
assert!(local_network_diagnostic(&r, None).is_some());
}

#[test]
fn local_network_denied_with_proxy_warns() {
let r = base_request();
let addr = ProxyAddress::new("127.0.0.1".into(), 8080);
assert!(local_network_diagnostic(&r, Some(&addr)).is_some());
}

#[test]
fn local_network_allowed_under_private_netns_warns() {
let mut r = base_request();
r.policy.allow_local_network = true;
let msg = local_network_diagnostic(&r, None).expect("--unshare-net isolates the listener");
assert!(msg.contains("allowLocalNetwork=true"));
}

#[test]
fn local_network_allowed_on_shared_netns_is_honored() {
let mut r = base_request();
r.policy.allow_local_network = true;
r.policy.default_network_policy = NetworkPolicy::Allow;
assert!(local_network_diagnostic(&r, None).is_none());
}

#[test]
fn filesystem_policy_produces_correct_mounts() {
let mut r = base_request();
Expand Down
3 changes: 3 additions & 0 deletions src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ impl BubblewrapScriptRunner {
// 2. Build the bwrap argument vector. `denied_files` is the file-mask
// subset classified during symlink resolution (see
// [`resolve_denied_paths`]).
if let Some(warning) = bwrap_command::local_network_diagnostic(request, proxy.address()) {
let _ = writeln!(logger, "{}", warning);
Comment on lines +194 to +195

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Your reading of the logger plumbing is correct, but the conclusion overstates what this PR changed β€” and the fix you propose is a different, much larger PR.

logger.log_line("WARNING: ...") is the established mechanism for preflight policy warnings in this codebase. The closest analogue is Bubblewrap's own cooperative-proxy warning at src/core/wxc_common/src/config_parser.rs:918, which tells the caller that defaultPolicy: "block" is not actually enforced for raw-socket clients β€” a security-relevant no-op warning with byte-for-byte identical visibility characteristics. Same for learningMode (config_parser.rs:714), which warns that AppContainer restrictions are not enforced at all, and the non-existent-path warning at config_parser.rs:367.

So the buffered-logger limitation you describe is real, but it is a property of the logging architecture that predates this change and applies uniformly to every preflight warning mxc emits. This PR moves allowLocalNetwork from not read at all to diagnosable through the same channel every other policy warning uses. That is the whole intent, and it is strictly better than the status quo.

What you are asking for β€” "propagate preflight warnings through an API/response channel that the CLI and SDK surfaces consume" β€” means adding a warnings field to ScriptResponse (src/core/wxc_common/src/models.rs:748, which has no such field today) and threading it through lxc-exec/wxc-exec, mxc-sdk, mxc_ffi's MxcRunResult, the C# SDK, and the TypeScript SDK. That is a cross-cutting API change affecting every backend and all three language bindings. Doing it here would bury a scoped Bubblewrap fix inside an SDK-surface redesign, and it should be decided on its own merits with maintainer input.

Not resolving this thread β€” leaving it open deliberately so a maintainer can weigh in on whether the warnings channel is wanted as a follow-up. Happy to file it as a separate issue if that is the preference.

}
let args = bwrap_command::build_args_classified(request, proxy.address(), denied_files);
let _ = writeln!(
logger,
Expand Down
Loading