[Bubblewrap] Reject network policy that cannot be enforced on schema 0.8+ - #943
[Bubblewrap] Reject network policy that cannot be enforced on schema 0.8+#943Soham Das (SohamDas2021) wants to merge 2 commits into
Conversation
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
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Adds schema 0.8+ fail-closed validation for unsupported Bubblewrap network policies while preserving legacy behavior.
Changes:
- Rejects unsupported firewall and local-network policies.
- Adds regression tests for schema compatibility.
- Corrects Bubblewrap networking documentation.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/core/wxc_common/src/config_parser.rs |
Adds schema-gated firewall validation. |
src/backends/bubblewrap/common/src/bwrap_runner.rs |
Rejects unenforceable local-network requests. |
src/backends/bubblewrap/common/src/bwrap_command.rs |
Centralizes namespace mismatch detection. |
tests/scripts/run_bwrap_network_test.sh |
Adds firewall rejection E2E checks. |
tests/configs/bubblewrap_network_firewall_rejected.json |
Adds schema 0.8 rejection fixture. |
docs/bwrap-support/bubblewrap-backend.md |
Documents actual network limitations. |
Suppressed comments (1)
src/core/wxc_common/src/config_parser.rs:1201
- The immediately following guard still emits the old claim that iptables enforcement requires privilege (lines 1211–1212), contradicting this PR's corrected missing-veth rationale. Rootless user namespaces can provide namespace-scoped
CAP_NET_ADMIN; the structural problem is that Bubblewrap exposes no host-side veth on which this manager can hook its chain. Update the emitted diagnostic so users are not directed toward elevation as a solution.
// 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.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/backends/bubblewrap/common/src/bwrap_runner.rs:116
- The fail-closed firewall check is still parser-only.
ExecutionRequestandSandboxBackendare public, andspawn()explicitly supports requests that bypassconfig_parser; such a schema-0.8 request withFirewall/Bothreachesneeds_iptables_rules()and the missing-veth path unchanged. Mirror the firewall-mode rejection in backend validation as well (using the shared schema predicate), so every execution entry point fails closed.
if let Some(reason) = bwrap_command::local_network_rejection(request) {
return Err(ScriptResponse::error(reason));
tests/scripts/run_bwrap_network_test.sh:36
- This test discards the process status and passes solely on matching text, so a regression that logs the warning but exits 0 would still be reported as a successful fail-closed rejection. Preserve and assert the non-zero status as well as the diagnostic.
FIREWALL_OUTPUT=$("$LXC_EXEC" --experimental \
"$REPO_DIR/tests/configs/bubblewrap_network_firewall_rejected.json" 2>&1 || true)
src/backends/bubblewrap/common/src/bwrap_command.rs:156
- This is now a hard validation error, but the diagnostic omits host rules even though either host list also makes
uses_private_netns()false. For requests like the new validator test (defaultPolicy=block, no proxy, butblockedHosts), the message blames neither actual cause and recommends settings the caller already has. Include host rules in both the cause and remediation.
"Bubblewrap: network.allowLocalNetwork=false is not enforced while the \
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
docs/sandbox-policy/0.8.0/networking/networking.md:396
- Calling Model 2 “enforced” is incorrect on this branch. Bubblewrap’s current proxy path only injects proxy environment variables while sharing the host network namespace, so raw sockets and clients that ignore those variables can bypass it, as
docs/bwrap-support/bubblewrap-backend.md:310-318documents. The private-netns enforcement from #930/#931 is explicitly not part of this PR, so this should describe the proxy as cooperative rather than enforced.
> 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
d384303 to
a5501bf
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/backends/bubblewrap/common/src/bwrap_command.rs:139
- Direct
ExecutionRequestcallers remain fail-open here: an enabled Bubblewrap proxy is cooperative, not an enforcing network boundary, so non-proxy-aware clients can bypass these host rules. Gate this exemption on an actually enforcing proxy network mode (or reject it on the current implementation), mirroring the parser change;network_proxy.is_enabled()alone is insufficient.
let unenforced = matches!(
request.policy.network_enforcement_mode,
NetworkEnforcementMode::Capabilities
) && !request.policy.network_proxy.is_enabled();
docs/sandbox-policy/0.8.0/networking/networking.md:396
- This says model 2 is enforced today, but the current Bubblewrap proxy is cooperative and leaves direct egress available to clients that ignore
HTTP_PROXY/HTTPS_PROXY(docs/bwrap-support/bubblewrap-backend.md:310-319). That enforced proxy-only boundary belongs to the follow-up work, so the implementation-status text should not claim it already exists.
> endpoint — is the enforced path today. Delivering the chain described above
| && !policy.network_proxy.is_enabled() | ||
| && (!policy.allowed_hosts.is_empty() || !policy.blocked_hosts.is_empty()) |
There was a problem hiding this comment.
Confirmed, and reproduced. With version: 0.8.0-alpha, defaultPolicy: block, allowedHosts: ["api.github.com"] and the builtin proxy, the sandbox shares the host network namespace and a direct request to a host that is not on the allowlist succeeds:
NETNS=net:[4026531833] <- identical to the host netns
DIRECT_RC=200 <- http://example.com, not on the allowlist
Host lists suppress --unshare-net (uses_private_netns requires allowed_hosts.is_empty()), and the proxy is cooperative env-var injection only, so curl --noproxy '*' walks straight out. So is_enabled() is not an enforcement mechanism here, exactly as you describe.
Deferring the fix to the follow-up firewall-enforcement PR (Bundle B) rather than taking it here, because the two changes are coupled. Bundle B makes enforcementMode: "firewall" genuinely enforce, via iptables programmed inside the sandbox's own netns by a supervisor holding namespace-scoped CAP_NET_ADMIN (no elevation). Removing the proxy carve-out in isolation now would make host lists entirely unusable on Bubblewrap at 0.8 — firewall mode rejected, capabilities-without-proxy rejected, proxy rejected — leaving no accepted configuration. Landing it together with Bundle B means the carve-out is replaced by a mechanism that actually applies the lists, instead of by a dead end.
Bundle B also narrows this PR's blanket firewall rejection to hostname rule addresses only, per D3, so both gates are revised in the same change. Tracked there; pre-0.8 behaviour is unchanged either way.
Description
Summary
Two Bubblewrap network settings were accepted and then not enforced, so a run that asked for a restriction exited 0 having applied nothing — the worst failure shape for a security control, because it looks enforced.
enforcementMode: "firewall"/"both"—NetworkIptablesManagerscopes its chain to a container's host-side veth so it can be hooked intoFORWARD. Unprivileged Bubblewrap has no veth, sobwrap_runnercallsallow_missing_veth_interface()and the chain is built but never attached. AdefaultPolicy: "block"allowlist filtered nothing; the warning went to stdout only, never to the exit code.allowLocalNetwork— when the namespace cannot satisfy the request, this was a warning only.Both now fail closed at parse/validate time. Schema 0.6/0.7 keep their current behaviour exactly, so existing callers are unaffected; the gate is 0.8+ only and is shared via
wxc_common::config_parser::schema_enforces_network_strictlyso there is no second copy of the version check to drift.Also corrects three claims in
bubblewrap-backend.md, including"still works but requires root", and replaces the staleCAP_NET_ADMINrationale on the existing guard — the real reason is the missing veth, not privilege (namespace-scopedCAP_NET_ADMINis rootless).Validation
cargo test -p bwrap_common69 pass ·cargo test -p wxc_common631+22+54 pass ·fmt · clippy
-D warnings·validate-configs.js231 configs ·run_bwrap_network_test.shextended with both directions: schema 0.8 is rejected, schema 0.6 is explicitly not rejected.Legacy behaviour is pinned by test, not just by claim:
bubblewrap_firewall_enforcement_is_accepted_before_0_8,local_network_mismatch_is_not_rejected_before_0_8,validate_accepts_the_same_request_before_0_8.Notes for reviewers
allowLocalNetworkcheck lives in the backend, not the parser, on purpose. A hardcoded "reject at 0.8" inconfig_parseris correct onmainbut becomes wrong once [Bubblewrap] Run proxy mode in a private network namespace via rootless slirp4netns (schema 0.8.0+) #930/[Bubblewrap] Enforce proxy-only egress for private networking #931 merge: 0.8 + proxy then gets a private netns, which genuinely does satisfy the deny. Keying offlocal_network_diagnosticmeans the rejection narrows automatically when that lands, instead of falsely rejecting valid configs.builtinTestServerhas no address until the proxy starts, which is after validation. Pinned bybuiltin_test_server_counts_as_an_active_proxy_when_rejecting.tests/configs/bubblewrap_network_firewall.json(0.6) is unchanged and now serves as the legacy-compatibility fixture; the 0.8 rejection case is a new separate file.main, independent of the [Bubblewrap] Run proxy mode in a private network namespace via rootless slirp4netns (schema 0.8.0+) #930 → [Bubblewrap] Enforce proxy-only egress for private networking #931 → host-pin stack.enforcementMode: "firewall"at 0.8 by programming iptables rules directly into the sandbox's own network namespace from the supervisor, rather than hooking a host-side chain to a veth that unprivileged bwrap does not have. When that lands, the 0.8 firewall rejection here is removed and replaced by real enforcement; theallowLocalNetworkhalf of this PR is unaffected. Landing this first keeps 0.8 fail-closed in the interim instead of silently unenforced.Related Issues
AB#63099833 (the "reject unsupported" half; "map supported elements" remains blocked on the GA
egress/ingresswire contract, which does not yet exist)I have signed the Contributor License Agreement.
This pull request is related to an issue.
If this PR changes build commands, project architecture, or key conventions, I have updated
.github/copilot-instructions.md.GitHub Actions runs the PR validation build automatically. The ADO pipeline
(
MXC-PR-Build) is the Azure version of the PR pipeline, kept in parity with the GitHubActions build; it runs on merge to
main, and Microsoft reviewers with write access can trigger iton a PR with
/azp run. See docs/pull-requests.md.If the
dependency-feed-checkcheck fails on a new dependency, the crate must be added tothe feed before the PR can pass. See docs/pull-requests.md
for the steps.
Microsoft Reviewers: Open in CodeFlow