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/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 8fed24071..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 @@ -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) +} + +/// 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 @@ -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.", @@ -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()); } @@ -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(); diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index fb6c97127..a92a6d72e 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -108,6 +108,24 @@ 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. + // + // 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)); + } + // `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 +235,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 +756,176 @@ 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. 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!( + 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_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 c3e393771..d2afe86f7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -294,6 +294,47 @@ 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 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(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> { @@ -1165,9 +1206,11 @@ 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 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!( @@ -1182,6 +1225,42 @@ fn convert_wire_config( 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 + // 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 = BWRAP_FIREWALL_UNSUPPORTED; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + + // 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 + && schema_enforces_network_strictly(&schema_version) + && matches!( + policy.network_enforcement_mode, + NetworkEnforcementMode::Capabilities + ) + && !policy.network_proxy.is_enabled() + && (!policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty()) + { + let msg = BWRAP_UNENFORCED_HOST_RULES; + logger.log_line(msg); + return Err(WxcError::ConfigParse(msg.to_string())); + } + // Seatbelt has no privileged packet-filter layer on macOS: it enforces // network policy through the sandbox profile (capabilities-style) and // ignores enforcementMode. Combining network.proxy with a firewall mode @@ -4082,6 +4161,120 @@ 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 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 @@ -5217,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/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..8a331f2ab 100644 --- a/tests/scripts/run_bwrap_network_test.sh +++ b/tests/scripts/run_bwrap_network_test.sh @@ -26,3 +26,56 @@ 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)..." +set +e +FIREWALL_OUTPUT=$("$LXC_EXEC" --experimental \ + "$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." +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)..." +set +e +LEGACY_OUTPUT=$("$LXC_EXEC" --experimental \ + "$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."