From 0b2b2fc10f6012113eff87755b537bd4b25de1fc Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 18 Aug 2026 17:09:07 -0700 Subject: [PATCH 1/2] Reject Bubblewrap network policy that cannot be enforced on schema 0.8+ Firewall enforcement modes build an iptables chain that is never hooked into FORWARD, because unprivileged Bubblewrap has no veth to scope it to, so the run reported success having filtered nothing. An allowLocalNetwork request the namespace cannot satisfy was likewise only warned about. Both now fail closed on schema 0.8 and later. Pre-0.8 configs keep their current behavior so existing callers are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae9db9df-6439-46c3-9cb4-fd70e7cc43a3 --- docs/bwrap-support/bubblewrap-backend.md | 39 +++++---- .../bubblewrap/common/src/bwrap_command.rs | 82 ++++++++++++++++-- .../bubblewrap/common/src/bwrap_runner.rs | 45 +++++++++- src/core/wxc_common/src/config_parser.rs | 85 ++++++++++++++++++- .../bubblewrap_network_firewall_rejected.json | 14 +++ tests/scripts/run_bwrap_network_test.sh | 30 +++++++ 6 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 tests/configs/bubblewrap_network_firewall_rejected.json diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 6232845d8..1267df1a6 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -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. @@ -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. @@ -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: ` or `url: ` (not `builtinTestServer`), the external proxy is responsible for any host filtering. The runner does @@ -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` diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index 8fed24071..56e87472f 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -93,11 +93,30 @@ 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 [`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 @@ -123,19 +142,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.", @@ -197,7 +222,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()); } @@ -406,6 +431,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(); diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index fb6c97127..6a528918c 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -108,6 +108,14 @@ impl SandboxBackend for BubblewrapScriptRunner { } } + // Schema 0.8+ fails closed on a network element Bubblewrap cannot + // honor, rather than running more permissive than requested. Pre-0.8 + // keeps the warning, so existing configs are unaffected. Sits with the + // input checks so a host without bwrap is still told what is wrong. + if let Some(reason) = bwrap_command::local_network_rejection(request) { + return Err(ScriptResponse::error(reason)); + } + // `bwrap` must be present *and* new enough for every flag the argument // builder emits — an old binary would otherwise fail at spawn time with // an opaque "unknown option" error. @@ -217,7 +225,7 @@ impl BubblewrapScriptRunner { // 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); + let _ = writeln!(logger, "WARNING: {}", warning); } let args = bwrap_command::build_args_classified(request, proxy.address(), denied_files); let _ = writeln!( @@ -738,6 +746,41 @@ mod tests { assert!(runner.validate(&req).is_ok()); } + #[test] + fn validate_rejects_an_unhonorable_local_network_request_at_0_8() { + // Host rules keep the sandbox on the shared netns, so allowLocalNetwork + // =false cannot be honored. Runs ahead of the bwrap probe, so this holds + // on hosts without bwrap installed. + let mut req = base_request(); + req.schema_version = "0.8.0-alpha".into(); + req.policy.blocked_hosts = vec!["evil.example.com".into()]; + + let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); + assert!( + err.error_message.contains("allowLocalNetwork=false"), + "unexpected error: {}", + err.error_message + ); + } + + #[test] + fn validate_accepts_the_same_request_before_0_8() { + // Pre-0.8 warns at run time instead of failing, so existing callers are + // unaffected. Tolerant of a host without bwrap: it only rules out the + // local-network rejection. + let mut req = base_request(); + req.schema_version = "0.7.0-alpha".into(); + req.policy.blocked_hosts = vec!["evil.example.com".into()]; + + if let Err(err) = BubblewrapScriptRunner::new().validate(&req) { + assert!( + !err.error_message.contains("allowLocalNetwork"), + "0.7 must not be rejected for allowLocalNetwork: {}", + err.error_message + ); + } + } + #[test] fn validate_rejects_empty_script_before_environment_probe() { // Empty script_code is a user-input error and must be surfaced diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index c3e393771..e096a6566 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -294,6 +294,15 @@ const CURRENT_SCHEMA_VERSION: &str = "0.8.0-alpha"; /// section or graduating one from experimental. const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isolation_session"]; +/// Whether a schema version opts into the 0.8 network contract, under which a +/// network element the backend cannot enforce is a hard error rather than a +/// warning. An absent or unparsable version is treated as pre-0.8. +pub fn schema_enforces_network_strictly(version: &str) -> bool { + semver::Version::parse(version) + .map(|parsed| (parsed.major, parsed.minor) >= (0, 8)) + .unwrap_or(false) +} + /// Validate that the schema version (semver) is supported by this binary. /// Compares major.minor only — patch and pre-release labels are ignored. fn validate_schema_version(version: &str) -> Result<(), WxcError> { @@ -1165,9 +1174,31 @@ fn convert_wire_config( } } - // Bubblewrap is unprivileged by design; iptables-based enforcement - // (firewall / both) requires CAP_NET_ADMIN, which defeats the backend's - // privilege story. Reject the combination explicitly. + // Bubblewrap's iptables chain is scoped to a container's host-side veth + // so it can be hooked into FORWARD. Unprivileged Bubblewrap has no + // veth, so `bwrap_runner` calls `allow_missing_veth_interface()` and the + // chain is built but never attached: the run reports success having + // enforced nothing. Schema 0.8+ fails closed instead. Pre-0.8 keeps its + // current behavior so existing configs are unaffected. + if containment == ContainmentBackend::Bubblewrap + && schema_enforces_network_strictly(&schema_version) + && matches!( + policy.network_enforcement_mode, + NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both + ) + { + let msg = "Bubblewrap: network.enforcementMode='firewall' and 'both' are not \ + supported. Unprivileged Bubblewrap has no veth interface to scope the \ + iptables chain to, so the rules would never be reached from FORWARD and \ + the policy would not be enforced. Use network.proxy with \ + enforcementMode='cooperative' instead."; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + + // Bubblewrap's cooperative env-var proxy enforces hosts at the proxy + // layer, which is mutually exclusive with iptables-based enforcement. + // Rejected at every schema version, unlike the guard above. if containment == ContainmentBackend::Bubblewrap && policy.network_proxy.is_enabled() && matches!( @@ -4082,6 +4113,54 @@ mod tests { ); } + #[test] + fn bubblewrap_firewall_enforcement_is_rejected_at_0_8() { + // The chain is built but never hooked into FORWARD (no veth), so the + // run would report success having enforced nothing. + let json = r#"{ + "version": "0.8.0-alpha", + "containment": "bubblewrap", + "process": {"commandLine": "echo hi"}, + "network": { + "defaultPolicy": "block", + "allowedHosts": ["api.github.com"], + "enforcementMode": "firewall" + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("network.enforcementMode='firewall' and 'both' are not supported"), + "unexpected error message: {}", + msg + ); + } + + #[test] + fn bubblewrap_firewall_enforcement_is_accepted_before_0_8() { + // Pre-0.8 keeps its current behavior; the fail-closed gate is 0.8+ only + // so existing 0.6/0.7 callers are unaffected. + let json = r#"{ + "version": "0.7.0-alpha", + "containment": "bubblewrap", + "process": {"commandLine": "echo hi"}, + "network": { + "defaultPolicy": "block", + "allowedHosts": ["api.github.com"], + "enforcementMode": "firewall" + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true) + .expect("0.7 firewall configs must keep parsing"); + assert_eq!(req.schema_version, "0.7.0-alpha"); + } + #[test] fn proxy_remote_url_with_seatbelt_and_default_block_is_rejected() { // A remote (non-loopback) proxy under default-deny would degrade the diff --git a/tests/configs/bubblewrap_network_firewall_rejected.json b/tests/configs/bubblewrap_network_firewall_rejected.json new file mode 100644 index 000000000..e1399d047 --- /dev/null +++ b/tests/configs/bubblewrap_network_firewall_rejected.json @@ -0,0 +1,14 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-Bubblewrap-Network-Firewall-Rejected", + "containment": "bubblewrap", + "process": { + "commandLine": "wget -qO- https://api.github.com/zen" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": ["api.github.com"], + "blockedHosts": ["evil.example.com"] + } +} diff --git a/tests/scripts/run_bwrap_network_test.sh b/tests/scripts/run_bwrap_network_test.sh index 66368d654..1f14b9cbe 100644 --- a/tests/scripts/run_bwrap_network_test.sh +++ b/tests/scripts/run_bwrap_network_test.sh @@ -26,3 +26,33 @@ else exit 1 fi echo "Bubblewrap network block test complete." + +# Schema 0.8 must fail closed on firewall mode rather than building an iptables +# chain that is never hooked into FORWARD and reporting success. +REJECTION="enforcementMode='firewall' and 'both' are not supported" + +echo "Running Bubblewrap firewall-mode rejection test (schema 0.8)..." +FIREWALL_OUTPUT=$("$LXC_EXEC" --experimental \ + "$REPO_DIR/tests/configs/bubblewrap_network_firewall_rejected.json" 2>&1 || true) + +if echo "$FIREWALL_OUTPUT" | grep -qF "$REJECTION"; then + echo "PASS: schema 0.8 firewall mode rejected rather than silently unenforced." +else + echo "$FIREWALL_OUTPUT" + echo "FAIL: schema 0.8 firewall mode should be rejected." + exit 1 +fi + +# The same policy on 0.6 keeps its existing behavior. Asserts only that the new +# gate does not fire — whether iptables itself succeeds depends on privilege. +echo "Running Bubblewrap firewall-mode legacy test (schema 0.6)..." +LEGACY_OUTPUT=$("$LXC_EXEC" --experimental \ + "$REPO_DIR/tests/configs/bubblewrap_network_firewall.json" 2>&1 || true) + +if echo "$LEGACY_OUTPUT" | grep -qF "$REJECTION"; then + echo "$LEGACY_OUTPUT" + echo "FAIL: schema 0.6 firewall mode must not be rejected." + exit 1 +fi +echo "PASS: schema 0.6 firewall mode still accepted." +echo "Bubblewrap firewall-mode tests complete." From a5501bfc2d7df772c23be71f9c365d8760bb842d Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 18 Aug 2026 20:02:02 -0700 Subject: [PATCH 2/2] Addressed PR comments --- .../0.8.0/networking/networking.md | 14 +- .../bubblewrap/common/src/bwrap_command.rs | 39 +++- .../bubblewrap/common/src/bwrap_runner.rs | 147 +++++++++++++- src/core/wxc_common/src/config_parser.rs | 189 ++++++++++++++++-- tests/scripts/run_bwrap_network_test.sh | 27 ++- 5 files changed, 395 insertions(+), 21 deletions(-) diff --git a/docs/sandbox-policy/0.8.0/networking/networking.md b/docs/sandbox-policy/0.8.0/networking/networking.md index 9dc550f6e..58228f2fb 100644 --- a/docs/sandbox-policy/0.8.0/networking/networking.md +++ b/docs/sandbox-policy/0.8.0/networking/networking.md @@ -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:** @@ -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. diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index 56e87472f..3e304aa5b 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -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 @@ -106,6 +106,43 @@ 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) +} + +/// 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. /// diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index 6a528918c..a92a6d72e 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -112,6 +112,16 @@ impl SandboxBackend for BubblewrapScriptRunner { // honor, rather than running more permissive than requested. Pre-0.8 // keeps the warning, so existing configs are unaffected. Sits with the // input checks so a host without bwrap is still told what is wrong. + // + // The parser rejects both of these too, but only sees JSON configs; a + // Rust caller can build an `ExecutionRequest` and reach the runner + // directly, so the checks are repeated here rather than assumed. + if let Some(reason) = bwrap_command::firewall_mode_rejection(request) { + return Err(ScriptResponse::error(reason)); + } + if let Some(reason) = bwrap_command::unenforced_host_rules_rejection(request) { + return Err(ScriptResponse::error(reason)); + } if let Some(reason) = bwrap_command::local_network_rejection(request) { return Err(ScriptResponse::error(reason)); } @@ -750,10 +760,15 @@ mod tests { fn validate_rejects_an_unhonorable_local_network_request_at_0_8() { // Host rules keep the sandbox on the shared netns, so allowLocalNetwork // =false cannot be honored. Runs ahead of the bwrap probe, so this holds - // on hosts without bwrap installed. + // on hosts without bwrap installed. The proxy is what makes the host + // rules legal at 0.8 -- without a mechanism they are refused earlier. let mut req = base_request(); req.schema_version = "0.8.0-alpha".into(); req.policy.blocked_hosts = vec!["evil.example.com".into()]; + req.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)), + builtin_test_server: false, + }; let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); assert!( @@ -781,6 +796,136 @@ mod tests { } } + #[test] + fn validate_rejects_a_firewall_mode_request_at_0_8() { + // The parser rejects this too, but a Rust caller can build the request + // directly and never pass through it. Without this check the chain is + // built, never attached, and the run reports success having enforced + // nothing -- the exact fail-open this gate exists to close. + let mut req = base_request(); + req.schema_version = "0.8.0-alpha".into(); + req.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + req.policy.allowed_hosts = vec!["api.github.com".into()]; + req.policy.allow_local_network = true; + + let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); + assert!( + err.error_message.contains("enforcementMode='firewall'"), + "unexpected error: {}", + err.error_message + ); + } + + #[test] + fn validate_rejects_both_mode_request_at_0_8() { + // 'both' is the same unattached chain as 'firewall'. + let mut req = base_request(); + req.schema_version = "0.8.0-alpha".into(); + req.policy.network_enforcement_mode = NetworkEnforcementMode::Both; + req.policy.allow_local_network = true; + + let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); + assert!( + err.error_message.contains("enforcementMode='firewall'"), + "unexpected error: {}", + err.error_message + ); + } + + #[test] + fn validate_accepts_a_firewall_mode_request_before_0_8() { + // GHCP consumes Bubblewrap on 0.6/0.7; the gate must not reach them. + let mut req = base_request(); + req.schema_version = "0.7.0-alpha".into(); + req.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + req.policy.allowed_hosts = vec!["api.github.com".into()]; + req.policy.allow_local_network = true; + + if let Err(err) = BubblewrapScriptRunner::new().validate(&req) { + assert!( + !err.error_message.contains("enforcementMode"), + "0.7 must not be rejected for enforcementMode: {}", + err.error_message + ); + } + } + + #[test] + fn validate_rejects_host_rules_no_mechanism_will_enforce_at_0_8() { + // The gap this closes: host lists suppress --unshare-net, but under + // 'capabilities' with no proxy nothing applies them, so a default-deny + // policy ran with fully open egress on the host's namespace. + let mut req = base_request(); + req.schema_version = "0.8.0-alpha".into(); + req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Block; + req.policy.allowed_hosts = vec!["api.github.com".into()]; + req.policy.allow_local_network = true; + + let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); + assert!( + err.error_message + .contains("require an enforcement mechanism"), + "unexpected error: {}", + err.error_message + ); + } + + #[test] + fn validate_accepts_host_rules_when_a_proxy_enforces_them_at_0_8() { + // The proxy is the mechanism, so the same lists are fine with one. + let mut req = base_request(); + req.schema_version = "0.8.0-alpha".into(); + req.policy.blocked_hosts = vec!["evil.example.com".into()]; + req.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".into(), 3128)), + builtin_test_server: false, + }; + + if let Err(err) = BubblewrapScriptRunner::new().validate(&req) { + assert!( + !err.error_message + .contains("require an enforcement mechanism"), + "a proxy enforces the lists: {}", + err.error_message + ); + } + } + + #[test] + fn validate_accepts_host_rules_without_a_mechanism_before_0_8() { + // GHCP consumes Bubblewrap on 0.6/0.7 with exactly this shape. + let mut req = base_request(); + req.schema_version = "0.7.0-alpha".into(); + req.policy.default_network_policy = wxc_common::models::NetworkPolicy::Block; + req.policy.allowed_hosts = vec!["api.github.com".into()]; + + if let Err(err) = BubblewrapScriptRunner::new().validate(&req) { + assert!( + !err.error_message + .contains("require an enforcement mechanism"), + "0.7 must not be rejected: {}", + err.error_message + ); + } + } + + /// A malformed non-empty version cannot come from the parser, so it is a + /// hand-built request; the typo must not buy pre-0.8 leniency. + #[test] + fn validate_rejects_a_firewall_mode_request_with_a_malformed_version() { + let mut req = base_request(); + req.schema_version = "0.8".into(); + req.policy.network_enforcement_mode = NetworkEnforcementMode::Firewall; + req.policy.allow_local_network = true; + + let err = BubblewrapScriptRunner::new().validate(&req).unwrap_err(); + assert!( + err.error_message.contains("enforcementMode='firewall'"), + "unexpected error: {}", + err.error_message + ); + } + #[test] fn validate_rejects_empty_script_before_environment_probe() { // Empty script_code is a user-input error and must be surfaced diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index e096a6566..d2afe86f7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -296,13 +296,45 @@ const KNOWN_EXPERIMENTAL_BACKENDS: &[&str] = &["windows_sandbox", "wslc", "isola /// Whether a schema version opts into the 0.8 network contract, under which a /// network element the backend cannot enforce is a hard error rather than a -/// warning. An absent or unparsable version is treated as pre-0.8. +/// warning. +/// +/// An absent version stays lenient: `validate_schema_version` accepts it, and +/// programmatic callers who never set one are the pre-0.8 population. A +/// malformed non-empty version is treated as strict instead of lenient -- +/// `validate_schema_version` would reject it outright, so the only way one +/// reaches here is a hand-built `ExecutionRequest` that skipped the parser, and +/// a typo like `"0.8"` must not silently buy pre-0.8 leniency. pub fn schema_enforces_network_strictly(version: &str) -> bool { + if version.is_empty() { + return false; + } semver::Version::parse(version) .map(|parsed| (parsed.major, parsed.minor) >= (0, 8)) - .unwrap_or(false) + .unwrap_or(true) } +/// Rejection text for a Bubblewrap firewall enforcement mode on schema 0.8+. +/// +/// Shared with the Bubblewrap runner's validate-time twin so the CLI and +/// programmatic paths cannot drift apart in wording. +pub const BWRAP_FIREWALL_UNSUPPORTED: &str = + "Bubblewrap: network.enforcementMode='firewall' and 'both' are not supported. \ + Unprivileged Bubblewrap has no veth interface to scope the iptables chain to, so the \ + rules would never be reached from FORWARD and the policy would not be enforced. Remove \ + network.enforcementMode (it defaults to 'capabilities') and set network.proxy to enforce \ + host filtering at the proxy layer instead."; + +/// Rejection text for host lists no mechanism will enforce, on schema 0.8+. +/// +/// Shared with the Bubblewrap runner's validate-time twin, as above. +pub const BWRAP_UNENFORCED_HOST_RULES: &str = + "Bubblewrap: network.allowedHosts and network.blockedHosts require an enforcement \ + mechanism. With enforcementMode='capabilities' (the default) and no network.proxy, \ + nothing applies them: the host lists suppress the --unshare-net full block, so the \ + sandbox shares the host network namespace with no filtering at all. Set network.proxy \ + to enforce host filtering at the proxy layer, or drop the host lists to get the \ + namespace-level block that defaultPolicy='block' applies on its own."; + /// Validate that the schema version (semver) is supported by this binary. /// Compares major.minor only — patch and pre-release labels are ignored. fn validate_schema_version(version: &str) -> Result<(), WxcError> { @@ -1174,6 +1206,25 @@ fn convert_wire_config( } } + // Bubblewrap's cooperative env-var proxy enforces hosts at the proxy + // layer, which is mutually exclusive with iptables-based enforcement. + // Rejected at every schema version, unlike the guard below. Sequenced + // first so a config that sets both gets the specific conflict rather + // than the 0.8 gate's "use network.proxy" advice, which it already is. + if containment == ContainmentBackend::Bubblewrap + && policy.network_proxy.is_enabled() + && matches!( + policy.network_enforcement_mode, + NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both + ) + { + let msg = "Bubblewrap: network.proxy cannot be combined with \ + network.enforcementMode='firewall' or 'both'. The cooperative \ + env-var proxy enforces hosts at the proxy layer; iptables-based \ + enforcement requires privilege and is mutually exclusive."; + return Err(WxcError::ConfigParse(msg.to_string())); + } + // Bubblewrap's iptables chain is scoped to a container's host-side veth // so it can be hooked into FORWARD. Unprivileged Bubblewrap has no // veth, so `bwrap_runner` calls `allow_missing_veth_interface()` and the @@ -1187,29 +1238,26 @@ fn convert_wire_config( NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both ) { - let msg = "Bubblewrap: network.enforcementMode='firewall' and 'both' are not \ - supported. Unprivileged Bubblewrap has no veth interface to scope the \ - iptables chain to, so the rules would never be reached from FORWARD and \ - the policy would not be enforced. Use network.proxy with \ - enforcementMode='cooperative' instead."; + let msg = BWRAP_FIREWALL_UNSUPPORTED; logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } - // Bubblewrap's cooperative env-var proxy enforces hosts at the proxy - // layer, which is mutually exclusive with iptables-based enforcement. - // Rejected at every schema version, unlike the guard above. + // Host lists suppress `--unshare-net` (the runner expects iptables to + // filter instead), but under 'capabilities' with no proxy nothing does: + // the sandbox shares the host namespace unfiltered. A default-deny + // policy then yields fully open egress, so 0.8+ fails closed. if containment == ContainmentBackend::Bubblewrap - && policy.network_proxy.is_enabled() + && schema_enforces_network_strictly(&schema_version) && matches!( policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both + NetworkEnforcementMode::Capabilities ) + && !policy.network_proxy.is_enabled() + && (!policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty()) { - let msg = "Bubblewrap: network.proxy cannot be combined with \ - network.enforcementMode='firewall' or 'both'. The cooperative \ - env-var proxy enforces hosts at the proxy layer; iptables-based \ - enforcement requires privilege and is mutually exclusive."; + let msg = BWRAP_UNENFORCED_HOST_RULES; + logger.log_line(msg); return Err(WxcError::ConfigParse(msg.to_string())); } @@ -4161,6 +4209,72 @@ mod tests { assert_eq!(req.schema_version, "0.7.0-alpha"); } + #[test] + fn bubblewrap_host_rules_without_a_mechanism_are_rejected_at_0_8() { + // Host lists suppress --unshare-net, but 'capabilities' (the default) + // with no proxy applies nothing: this ran with fully open egress. + let json = r#"{ + "version": "0.8.0-alpha", + "containment": "bubblewrap", + "process": {"commandLine": "echo hi"}, + "network": { + "defaultPolicy": "block", + "allowedHosts": ["api.github.com"] + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let err = load_request(&encoded, &mut logger, true).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("require an enforcement mechanism"), + "unexpected error message: {}", + msg + ); + } + + #[test] + fn bubblewrap_host_rules_with_a_proxy_are_accepted_at_0_8() { + // The proxy is the mechanism, so the same lists are honoured. Only the + // built-in test proxy pairs with host lists -- an external one is + // rejected earlier, since MXC does not forward host policy to it. + let json = r#"{ + "version": "0.8.0-alpha", + "containment": "bubblewrap", + "process": {"commandLine": "echo hi"}, + "network": { + "defaultPolicy": "block", + "allowedHosts": ["api.github.com"], + "proxy": {"builtinTestServer": true} + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + load_request(&encoded, &mut logger, true).expect("a proxy enforces the host lists at 0.8"); + } + + #[test] + fn bubblewrap_host_rules_without_a_mechanism_are_accepted_before_0_8() { + // GHCP consumes Bubblewrap on 0.6/0.7 with exactly this shape. + let json = r#"{ + "version": "0.7.0-alpha", + "containment": "bubblewrap", + "process": {"commandLine": "echo hi"}, + "network": { + "defaultPolicy": "block", + "allowedHosts": ["api.github.com"] + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true) + .expect("0.7 host-list configs must keep parsing"); + assert_eq!(req.schema_version, "0.7.0-alpha"); + } + #[test] fn proxy_remote_url_with_seatbelt_and_default_block_is_rejected() { // A remote (non-loopback) proxy under default-deny would degrade the @@ -5296,6 +5410,49 @@ mod tests { assert_eq!(req.schema_version, ""); } + // The predicate gates whether an unenforceable network element is a hard + // error or a warning, so a regression in it changes security behavior + // silently. Pinned directly rather than only through the call sites, which + // planned work is expected to replace. + #[test] + fn strict_network_enforcement_starts_at_0_8() { + assert!(!schema_enforces_network_strictly("0.7.9")); + assert!(!schema_enforces_network_strictly("0.7.0-alpha")); + assert!(schema_enforces_network_strictly("0.8.0-alpha")); + assert!(schema_enforces_network_strictly("0.8.0")); + assert!(schema_enforces_network_strictly("0.9.0")); + } + + #[test] + fn strict_network_enforcement_holds_past_1_0() { + // A tuple compare, so a major bump must not wrap back to lenient. + assert!(schema_enforces_network_strictly("1.0.0")); + assert!(schema_enforces_network_strictly("2.3.4")); + } + + #[test] + fn a_pre_release_label_does_not_lower_the_version() { + // semver orders 0.8.0-alpha *below* 0.8.0; comparing (major, minor) + // sidesteps that, which is the whole reason for the tuple. + assert!( + semver::Version::parse("0.8.0-alpha").unwrap() + < semver::Version::parse("0.8.0").unwrap() + ); + assert!(schema_enforces_network_strictly("0.8.0-alpha")); + } + + #[test] + fn an_absent_version_is_lenient_but_a_malformed_one_is_not() { + // Absent is the pre-0.8 programmatic caller: `validate_schema_version` + // accepts it, so it keeps its behavior. Malformed cannot come from the + // parser at all (it rejects these), so it is a hand-built request whose + // typo must not buy leniency. + assert!(!schema_enforces_network_strictly("")); + assert!(schema_enforces_network_strictly("x")); + assert!(schema_enforces_network_strictly("0.8")); + assert!(schema_enforces_network_strictly("0.7")); + } + #[test] fn schema_version_non_semver_rejected() { let json = r#"{"process": {"commandLine": "echo hi"}, "version": "x"}"#; diff --git a/tests/scripts/run_bwrap_network_test.sh b/tests/scripts/run_bwrap_network_test.sh index 1f14b9cbe..8a331f2ab 100644 --- a/tests/scripts/run_bwrap_network_test.sh +++ b/tests/scripts/run_bwrap_network_test.sh @@ -32,8 +32,19 @@ echo "Bubblewrap network block test complete." REJECTION="enforcementMode='firewall' and 'both' are not supported" echo "Running Bubblewrap firewall-mode rejection test (schema 0.8)..." +set +e FIREWALL_OUTPUT=$("$LXC_EXEC" --experimental \ - "$REPO_DIR/tests/configs/bubblewrap_network_firewall_rejected.json" 2>&1 || true) + "$REPO_DIR/tests/configs/bubblewrap_network_firewall_rejected.json" 2>&1) +FIREWALL_STATUS=$? +set -e + +# Both halves matter: a regression that prints the rejection and still exits 0 +# is the silent-success shape this case exists to catch. +if [ "$FIREWALL_STATUS" -eq 0 ]; then + echo "$FIREWALL_OUTPUT" + echo "FAIL: schema 0.8 firewall mode exited 0; the rejection must fail the run." + exit 1 +fi if echo "$FIREWALL_OUTPUT" | grep -qF "$REJECTION"; then echo "PASS: schema 0.8 firewall mode rejected rather than silently unenforced." @@ -46,13 +57,25 @@ fi # The same policy on 0.6 keeps its existing behavior. Asserts only that the new # gate does not fire — whether iptables itself succeeds depends on privilege. echo "Running Bubblewrap firewall-mode legacy test (schema 0.6)..." +set +e LEGACY_OUTPUT=$("$LXC_EXEC" --experimental \ - "$REPO_DIR/tests/configs/bubblewrap_network_firewall.json" 2>&1 || true) + "$REPO_DIR/tests/configs/bubblewrap_network_firewall.json" 2>&1) +LEGACY_STATUS=$? +set -e if echo "$LEGACY_OUTPUT" | grep -qF "$REJECTION"; then echo "$LEGACY_OUTPUT" echo "FAIL: schema 0.6 firewall mode must not be rejected." exit 1 fi + +# A non-zero status here is expected without privilege (iptables itself may +# fail), but it must never be a *config* rejection -- that would mean the 0.8 +# gate leaked into the legacy schema. +if echo "$LEGACY_OUTPUT" | grep -qF "Configuration parse error"; then + echo "$LEGACY_OUTPUT" + echo "FAIL: schema 0.6 firewall mode failed config validation (status $LEGACY_STATUS)." + exit 1 +fi echo "PASS: schema 0.6 firewall mode still accepted." echo "Bubblewrap firewall-mode tests complete."