Skip to content
Closed
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
39 changes: 21 additions & 18 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,28 +180,29 @@ reach in. Runs fully unprivileged.
}
```

**Per-host filtering** (`allowedHosts`/`blockedHosts`) — shares the host
network namespace and applies iptables rules via `NetworkIptablesManager`
(the same approach used by the LXC backend). **Requires root** for
iptables.
**Per-host filtering** (`allowedHosts`/`blockedHosts` with
`enforcementMode: "firewall"` or `"both"`) — **not supported.**
`NetworkIptablesManager` scopes its chain to a container's host-side veth so
it can be hooked into `FORWARD`. Unprivileged Bubblewrap has no veth, so the
chain is built but never attached: nothing is filtered.

> **IPv4 only.** Host names are resolved to IPv4 addresses only; AAAA
> records and IPv6 literals are silently dropped because `iptables` (the
> IPv4 tool) cannot accept IPv6 destinations. A host with only AAAA
> records is effectively unreachable under firewall mode. For dual-stack
> hosts, use proxy mode (below) instead.
> **Schema 0.8+ rejects `enforcementMode: "firewall"` and `"both"`** rather
> than reporting success on an unenforced policy. Pre-0.8 configs keep their
> existing behavior (accepted, not enforced) so current callers are
> unaffected. Use proxy mode below for per-host filtering.

```json
{
"network": {
"defaultPolicy": "block",
"enforcementMode": "firewall",
"allowedHosts": ["api.github.com"],
"blockedHosts": ["evil.example.com"]
"allowedHosts": ["api.github.com"]
}
}
```

The config above is rejected on schema `0.8.0-alpha` and later.

**Full allow** (`defaultPolicy: "allow"`, no host lists) — the sandbox
shares the host network namespace with no restrictions.

Expand All @@ -224,8 +225,9 @@ namespace choice alone decides the outcome:
| `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`
Rows 2 and 3 are rejected on schema `0.8.0-alpha` and later, and emit a
`WARNING:` line to the runner log at preflight on earlier schemas 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.

Expand Down Expand Up @@ -317,8 +319,9 @@ Bubblewrap because it requires **no root and no `CAP_NET_ADMIN`**.
`network.proxy` so the runner can apply `--unshare-net` instead.
- **Mutually exclusive with iptables enforcement**: setting
`network.proxy` together with `network.enforcementMode` of `"firewall"`
or `"both"` is rejected at config-parse time because iptables-based
enforcement requires root and would defeat the proxy's privilege story.
or `"both"` is rejected at config-parse time on every schema version.
Firewall mode enforces nothing under unprivileged Bubblewrap (see above),
so the proxy is the only working per-host mechanism.
- **External proxy delegates policy**: when `network.proxy` uses
`localhost: <port>` or `url: <url>` (not `builtinTestServer`), the
external proxy is responsible for any host filtering. The runner does
Expand Down Expand Up @@ -393,10 +396,10 @@ Test configs are in `tests/configs/bubblewrap_*.json`.
and `/usr/local` are invisible unless explicitly listed in
`readonlyPaths` / `readwritePaths`. There is no separate rootfs — the
visible paths are bind-mounted from the host.
- **Network filtering** — per-host `allowedHosts`/`blockedHosts` is best
- **Network filtering** — per-host `allowedHosts`/`blockedHosts` must be
done via the cooperative env-var **network proxy** (no privilege
required, see above). The legacy iptables path
(`network.enforcementMode: "firewall"` / `"both"`) still works but
requires root and is mutually exclusive with the proxy.
(`network.enforcementMode: "firewall"` / `"both"`) enforces nothing under
unprivileged Bubblewrap and is rejected on schema 0.8+.
- **No state-aware lifecycle** — Bubblewrap implements `ScriptRunner` only
(one-shot), not `StatefulSandboxBackend`
14 changes: 13 additions & 1 deletion docs/sandbox-policy/0.8.0/networking/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Throughout this document, the deny-all-except-proxy posture (the GA goal) refers
- **When allowed:** Only when explicitly allowed by IP/CIDR + port + protocol rules in `egress.allow`. In model 2 there is no direct egress path, so these connections are not possible regardless of any allow rules.
- **Use case:** e.g. SSH to a specific dev server, a direct TCP connection to a database, UDP to a specific endpoint, ICMP for diagnostics.
- **Caveat (coarse filtering):** Rules match IP/CIDR + port + protocol, not the application protocol. A port number does not identify a service (DNS need not use 53; a database may listen on any port), so allowing or denying a port is a blunt control rather than service-level filtering.
- **Enforcement:** WFP filters (Windows process containers), network namespace + iptables (WSLc/LXC/Bubblewrap). Model 1 for macOS is not supported for GA. Seatbelt cannot filter arbitrary destinations and macOS packet filtering is not fine-grained enough for per-sandbox scenarios out of the box.
- **Enforcement:** WFP filters (Windows process containers), network namespace + iptables (WSLc/LXC/Bubblewrap). Model 1 for macOS is not supported for GA. Seatbelt cannot filter arbitrary destinations and macOS packet filtering is not fine-grained enough for per-sandbox scenarios out of the box. Model 1 is **not yet enforced on Bubblewrap** — see "LXC and Bubblewrap: GA enforcement" below; a config requesting it on schema 0.8+ is rejected rather than silently unenforced.

**Example:**

Expand Down Expand Up @@ -387,6 +387,18 @@ LXC and Bubblewrap use iptables/nftables on the container network path. Their IN
and the host-to-container half of `ingress.hostLoopback`; routing and output policy enforce its container-to-host half.
Model 2 permits only the proxy endpoint.

> **Implementation status (Bubblewrap).** The above is the GA target, not
> current behavior. Unprivileged Bubblewrap has no host-side veth, so an
> iptables chain cannot be hooked into `FORWARD`; the chain is built and never
> attached. Rather than report success having enforced nothing, MXC **rejects**
> `network.enforcementMode` of `firewall` or `both` on schema 0.8+ (schema
> 0.6/0.7 keeps the previous warn-and-continue behavior). Model 2 — the proxy
> endpoint — is the enforced path today. Delivering the chain described above
> requires moving filtered mode into the sandbox's own network namespace and
> filtering on `OUTPUT` instead of `FORWARD`; `ingress.hostLoopback` is
> likewise parsed but not yet enforced. LXC is unaffected: it has a veth and
> runs privileged, and enforces as described.

### macOS (Seatbelt): GA enforcement

**Default stance:** Egress is confined to the loopback proxy port by the Seatbelt profile.
Expand Down
121 changes: 114 additions & 7 deletions src/backends/bubblewrap/common/src/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use std::collections::HashSet;

use wxc_common::filesystem_resolve::FsIntent;
use wxc_common::models::{ExecutionRequest, NetworkPolicy, ProxyAddress};
use wxc_common::models::{ExecutionRequest, NetworkEnforcementMode, NetworkPolicy, ProxyAddress};
use wxc_common::proxy_env::{is_managed_proxy_key, PROXY_SET_KEYS};

/// Read-only host paths bind-mounted into every Bubblewrap sandbox as the
Expand Down Expand Up @@ -93,11 +93,67 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
/// 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 {
fn uses_private_netns(request: &ExecutionRequest, proxy_active: bool) -> bool {
request.policy.default_network_policy == NetworkPolicy::Block
&& request.policy.allowed_hosts.is_empty()
&& request.policy.blocked_hosts.is_empty()
&& proxy_address.is_none()
&& !proxy_active
}

/// Whether this schema opts into rejecting network elements Bubblewrap cannot
/// honor. Pre-0.8 callers keep the warning, so existing configs still run.
fn rejects_unhonorable_network(request: &ExecutionRequest) -> bool {
wxc_common::config_parser::schema_enforces_network_strictly(&request.schema_version)
}

/// Validate-time twin of the parser's schema-0.8 firewall gate.
///
/// The parser only sees JSON configs; a Rust caller can hand `mxc_engine` an
/// `ExecutionRequest` directly and reach the runner without ever passing
/// through it. Both paths must refuse a mode whose chain is built and never
/// attached, so this shares the parser's message verbatim.
pub fn firewall_mode_rejection(request: &ExecutionRequest) -> Option<&'static str> {
if !rejects_unhonorable_network(request) {
return None;
}
matches!(
request.policy.network_enforcement_mode,
NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both
)
.then_some(wxc_common::config_parser::BWRAP_FIREWALL_UNSUPPORTED)
Comment thread
SohamDas2021 marked this conversation as resolved.
}

/// Validate-time twin of the parser's schema-0.8 unenforced-host-list gate.
///
/// Host lists suppress `--unshare-net` but nothing applies them under
/// 'capabilities' with no proxy, so the sandbox shares the host namespace
/// unfiltered. Same reachability argument as [`firewall_mode_rejection`]: a
/// Rust caller reaches the runner without passing through the parser.
pub fn unenforced_host_rules_rejection(request: &ExecutionRequest) -> Option<&'static str> {
if !rejects_unhonorable_network(request) {
return None;
}
let unenforced = matches!(
request.policy.network_enforcement_mode,
NetworkEnforcementMode::Capabilities
) && !request.policy.network_proxy.is_enabled();
let has_host_rules =
!request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty();

(unenforced && has_host_rules).then_some(wxc_common::config_parser::BWRAP_UNENFORCED_HOST_RULES)
}

/// Validate-time twin of [`local_network_diagnostic`]: the same mismatch, but
/// only for schemas that reject it rather than warn.
///
/// Keys off proxy *enablement* rather than a resolved address because
/// `builtinTestServer` has no address until the proxy starts, which is after
/// validation.
pub fn local_network_rejection(request: &ExecutionRequest) -> Option<&'static str> {
if !rejects_unhonorable_network(request) {
return None;
}
local_network_mismatch(request, request.policy.network_proxy.is_enabled())
}

/// Describe a `network.allowLocalNetwork` setting Bubblewrap cannot honor, or
Expand All @@ -123,19 +179,25 @@ pub fn local_network_diagnostic(
request: &ExecutionRequest,
proxy_address: Option<&ProxyAddress>,
) -> Option<&'static str> {
local_network_mismatch(request, proxy_address.is_some())
}

/// The mismatch text itself, carrying no severity prefix so it can serve both
/// the pre-0.8 warning and the 0.8+ rejection.
fn local_network_mismatch(request: &ExecutionRequest, proxy_active: bool) -> Option<&'static str> {
match (
request.policy.allow_local_network,
uses_private_netns(request, proxy_address),
uses_private_netns(request, proxy_active),
) {
(false, false) => Some(
"WARNING: Bubblewrap: network.allowLocalNetwork=false is not enforced while the \
"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 \
"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.",
Expand Down Expand Up @@ -197,7 +259,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.
if uses_private_netns(request, proxy_address) {
if uses_private_netns(request, proxy_address.is_some()) {
args.push("--unshare-net".into());
}

Expand Down Expand Up @@ -406,6 +468,51 @@ mod tests {
assert!(local_network_diagnostic(&r, None).is_none());
}

// ------- allowLocalNetwork rejection (schema 0.8+) ------------------

#[test]
fn local_network_mismatch_is_not_rejected_before_0_8() {
// A 0.7 proxy config shares the host netns and leaves allowLocalNetwork
// at its default false. That combination must keep warning rather than
// start failing, or every existing 0.6/0.7 proxy caller breaks.
let mut r = base_request();
r.schema_version = "0.7.0-alpha".into();
r.policy.network_proxy.address = Some(ProxyAddress::new("127.0.0.1".into(), 8080));
assert!(local_network_diagnostic(&r, r.policy.network_proxy.address.as_ref()).is_some());
assert!(local_network_rejection(&r).is_none());
}

#[test]
fn local_network_mismatch_is_rejected_at_0_8() {
let mut r = base_request();
r.schema_version = "0.8.0-alpha".into();
r.policy.default_network_policy = NetworkPolicy::Allow;
let msg = local_network_rejection(&r).expect("shared netns cannot honor the deny");
assert!(msg.contains("allowLocalNetwork=false"));
// The text is reused verbatim as an error, so it must carry no severity.
assert!(!msg.contains("WARNING"));
}

#[test]
fn honored_local_network_is_not_rejected_at_0_8() {
// block + no host rules + no proxy is a private netns, which satisfies
// allowLocalNetwork=false; nothing to reject.
let mut r = base_request();
r.schema_version = "0.8.0-alpha".into();
assert!(local_network_rejection(&r).is_none());
}

#[test]
fn builtin_test_server_counts_as_an_active_proxy_when_rejecting() {
// builtinTestServer has no address until the proxy starts, which is
// after validation, so the rejection keys off enablement instead.
let mut r = base_request();
r.schema_version = "0.8.0-alpha".into();
r.policy.network_proxy.builtin_test_server = true;
assert!(r.policy.network_proxy.address.is_none());
assert!(local_network_rejection(&r).is_some());
}

#[test]
fn filesystem_policy_produces_correct_mounts() {
let mut r = base_request();
Expand Down
Loading
Loading