[Bubblewrap] Enforce proxy-only egress for private networking - #931
Conversation
|
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
Enforces proxy-only Bubblewrap egress by installing namespace-local IPv4/IPv6 firewall rules before releasing workloads.
Changes:
- Adds fail-closed
MXC_EGRESSfirewall setup and dependency checks. - Validates proxy endpoints and updates network-mode handling.
- Adds automated/E2E coverage and 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/backends/bubblewrap/common/src/proxy_network.rs |
Implements namespace firewall enforcement. |
src/backends/bubblewrap/common/src/bwrap_runner.rs |
Resolves and applies proxy egress endpoints. |
src/backends/bubblewrap/common/src/bwrap_command.rs |
Clarifies host versus namespace firewall modes. |
tests/configs/bubblewrap_network_proxy_egress_denied.json |
Adds egress and tampering checks. |
tests/scripts/run_bwrap_network_proxy_test.sh |
Runs the new enforcement test. |
docs/bwrap-support/bubblewrap-backend.md |
Documents requirements and enforcement behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ccfc317 to
2fd1dd7
Compare
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 (4)
src/backends/bubblewrap/common/src/bwrap_runner.rs:257
- This endpoint check is only reached during
spawn_bwrap.ScriptRunner::runreturns success immediately aftervalidate_runnerfor a dry run, while this backend'svalidateonly probes the dependencies, so--dry-runincorrectly accepts hostname and routable IPv6 proxy endpoints that execution rejects. Perform the endpoint validation invalidateas well (with the existing loopback translation and builtin-proxy handling).
.ok_or_else(|| {
"Bubblewrap: proxy-only networking requires a resolved proxy address"
.to_string()
})
.and_then(proxy_network::ProxyEgress::from_address)
src/backends/bubblewrap/common/src/bwrap_command.rs:102
- The proxy-env section later in this same builder (lines 364–369) still says clients using raw sockets are “NOT enforced.” That now contradicts this mode's contract for schema 0.8 and can mislead future maintenance. Update that comment to distinguish cooperative routing from the new enforced egress boundary, while retaining the legacy-version qualification.
/// Cooperative proxy routing inside a slirp-backed private namespace, with
/// egress closed to everything but the proxy.
docs/bwrap-support/bubblewrap-backend.md:395
- This change knowingly leaves the user-facing parser diagnostic false:
config_parser.rs:1168-1182still says Bubblewrap iptables requires privilege and calls this a cooperative-only model, which this PR disproves. Update that diagnostic to state the actual reason firewall/both remains incompatible, rather than documenting an incorrect error message.
or `"both"` is rejected at config-parse time. (The rejection message cites a
root requirement that proxy mode has since disproved; see the firewall
section.)
src/backends/bubblewrap/common/src/proxy_network.rs:59
- The fixed descriptor
9can collide with any of the dynamically allocated descriptors passed to the supervisor. For example, ifready.as_raw_fd()is 9, this redirection replaces the external readiness file withslirp.internal; the final readiness write then goes to the wrong file and startup always times out. Allocate the internal readiness file/FD in Rust and pass its distinct descriptor to the script instead of hard-coding an FD number.
exec 9> "$state_dir/slirp.internal"
2fd1dd7 to
b20acf1
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 (3)
src/backends/bubblewrap/common/src/proxy_network.rs:509
- This translates every
127.0.0.0/8address, but slirp's10.0.2.2gateway maps specifically to host127.0.0.1. A configured proxy bound only to127.0.0.2therefore passes validation, is advertised as supported in the updated docs, and is rewritten to an endpoint where nothing listens. Reject non-127.0.0.1loopback literals (or add forwarding that preserves them) instead of treating the whole loopback range as reachable through the gateway.
|| parsed.is_some_and(|ip| ip.is_loopback() || ip.is_unspecified());
src/backends/bubblewrap/common/src/proxy_network.rs:39
- The deadline is exactly the sum of the nine maximum
-wwaits, butattachstarts it before slirp readiness and eachnsenter/iptables process also consumes execution and spawn time. Thus the fully contended case this budget claims to support necessarily exceeds the deadline and rejects a viable sandbox. Include slirp startup and command overhead (or enforce one explicit end-to-end deadline inside the supervisor) rather than sizing to the bare lock waits.
const RULE_INSTALL_TIMEOUT: Duration =
Duration::from_secs(XTABLES_LOCK_WAIT.as_secs() * RULE_COMMAND_COUNT as u64);
tests/configs/bubblewrap_network_proxy_egress_denied.json:6
- This assertion can pass without any MXC egress rule: on hosts that already block direct Internet access (a common reason to require a proxy), the connection to
1.1.1.1:443fails even if slirp remains unrestricted. The later proxied GitHub request only proves proxy connectivity, not that this direct destination was reachable before enforcement. Use a host-side control listener on a non-proxy port reachable through10.0.2.2(or establish an equivalent positive precondition) and assert that the sandbox cannot reach it.
"commandLine": "bash -c 'set -u; pu=\"${HTTP_PROXY:-${http_proxy:-}}\"; if [ -z \"$pu\" ]; then echo NO_PROXY_ENV; exit 1; fi; pa=\"${pu#*://}\"; pa=\"${pa%%/*}\"; ph=\"${pa%%:*}\"; pp=\"${pa##*:}\"; if [ \"$pp\" = \"$ph\" ]; then pp=80; fi; if ! timeout 6 bash -c \"exec 3<>/dev/tcp/$ph/$pp\" >/dev/null 2>&1; then echo CONTROL_PROXY_UNREACHABLE; exit 1; fi; echo CONTROL_PROXY_REACHABLE_OK; timeout 6 bash -c \"exec 3<>/dev/tcp/1.1.1.1/443\" >/dev/null 2>&1; rc=$?; if [ \"$rc\" = 0 ]; then echo DIRECT_EGRESS_LEAKED; exit 1; fi; echo DIRECT_EGRESS_BLOCKED_OK; timeout 4 bash -c \"exec 3<>/dev/tcp/127.0.0.1/9\" >/dev/null 2>&1; lrc=$?; if [ \"$lrc\" = 124 ]; then echo LOOPBACK_DROPPED; exit 1; fi; echo LOOPBACK_EXEMPT_OK; if ! command -v iptables >/dev/null 2>&1; then echo NO_IPTABLES_BINARY; exit 1; fi; capeff=$(grep \"^CapEff\" /proc/self/status | cut -f2); if [ -z \"$capeff\" ]; then echo NO_CAPEFF; exit 1; fi; if [ $((0x$capeff & 0x1000)) -ne 0 ]; then echo CAP_NET_ADMIN_RETAINED; exit 1; fi; echo CAP_NET_ADMIN_DROPPED_OK; terr=$(iptables -F MXC_EGRESS 2>&1); trc=$?; if [ \"$trc\" = 0 ]; then echo \"TAMPER_FLUSH_SUCCEEDED: $terr\"; exit 1; fi; echo \"TAMPER_REFUSED_OK rc=$trc\"; timeout 6 bash -c \"exec 3<>/dev/tcp/1.1.1.1/443\" >/dev/null 2>&1; if [ $? = 0 ]; then echo TAMPER_DISABLED_EGRESS; exit 1; fi; echo TAMPER_INEFFECTIVE_OK; if ! curl -fsSL --max-time 15 https://api.github.com/zen >/dev/null; then echo PROXY_UNREACHABLE; exit 1; fi; echo PROXY_STILL_OK'"
b20acf1 to
081e737
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 no new comments.
Suppressed comments (3)
src/backends/bubblewrap/common/src/proxy_network.rs:43
- The parent starts this timeout before slirp startup, but the budget is exactly the sum of the nine maximum
-wwaits. If each command legitimately waits close to five seconds, slirp startup and process-spawn overhead push readiness past 45 seconds, so concurrent provisioning can be killed even though every rule command is still within its own timeout. Reserve the startup allowance (and therefore some overhead) in this deadline.
const RULE_INSTALL_TIMEOUT: Duration =
Duration::from_secs(XTABLES_LOCK_WAIT.as_secs() * RULE_COMMAND_COUNT as u64);
src/backends/bubblewrap/common/src/proxy_network.rs:514
::does not guarantee a dual-stack listener:IPV6_V6ONLYmay be enabled per socket or vianet.ipv6.bindv6only. Rewriting an external[::]proxy to the IPv4 gateway therefore accepts a valid IPv6-only listener during validation but makes it unreachable at runtime. Reject::with the other IPv6 endpoints (matching this PR's IPv4-only contract), or establish an IPv4 listener before translating it; update the tests and docs that currently promise this translation.
// `::` is safe to rewrite to IPv4 because a dual-stack wildcard listener
// accepts IPv4 connections, which `::1` does not.
let is_host_local = host.eq_ignore_ascii_case("localhost")
|| parsed.is_some_and(|ip| ip.is_loopback() || ip.is_unspecified());
src/backends/bubblewrap/common/src/bwrap_command.rs:102
- This updated mode contract conflicts with the proxy-environment comment at lines 364–369, which still says raw sockets are “NOT enforced” and calls that a documented limitation. Scope that statement to
LegacyProxyor explain thatProxyOnlyenforces the destination with its in-namespace firewall, so future changes do not rely on the obsolete security model.
/// Cooperative proxy routing inside a slirp-backed private namespace, with
/// egress closed to everything but the proxy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ae9db9df-6439-46c3-9cb4-fd70e7cc43a3
081e737 to
46b10dc
Compare
📖 Description
The base PR moves schema-0.8+ Bubblewrap proxy sandboxes into a private network namespace, but
slirp4netnsroutes everything out of it — so the proxy is still only cooperative. A workload that ignoresHTTP_PROXY/HTTPS_PROXY, or opens a raw socket, reaches the network directly. This PR makes the boundary real: inside the namespace, the proxy endpoint is the only reachable destination. Everything else is dropped, IPv4 and IPv6.How it works
The supervisor (already introduced in the base PR) gains firewall responsibility:
slirp4netnsmoves fromexecto a backgrounded child with its own internal readiness fd, so the supervisor regains control once routing is up.MXC_EGRESSchain viansenter --net=/proc/<pid>/ns/net, hooked fromOUTPUT: acceptlo, accepttcp -d <proxy-ip> --dport <proxy-port>, DROP everything else. The IPv6 chain acceptsloand drops the rest.set -emeans a failed rule kills the supervisor rather than proceeding.bwrapjoins the supervisor's user namespace and drops capabilities, so the workload sits inside the namespace that owns its own firewall but cannot modify it. That assumption is load-bearing, so it is now both documented and asserted by a test (below).DNS is deliberately closed
No
:53accept rule is installed. An unscoped port-53 allowance is a standing DNS-tunnel exfiltration path, and this mirrors the policy already shipped for LXC model 2. Name resolution happens through the proxy.Limitations
localhost/127.0.0.0/8/0.0.0.0/::are rewritten to slirp's gateway, while::1is rejected (an IPv6-loopback listener cannot accept the gateway's IPv4 connection). This is a narrowing of what the base PR accepts — hostname support requires porting LXC'sProxyHostPin(already present inwxc_common::models) and is the immediate follow-up.nsenter,iptables,ip6tables, added to the existing validate-time probe. None require root.iptables/ip6tablesmust also resolve to thenf_tablesbackend unless/run/xtables.lockis writable — the legacy backend opens that lock before touching any table, which an unprivileged same-uid supervisor cannot do on a stock host.validaterefuses such a host rather than letting the supervisor die at the first rule.nsenter+iptablesspawns, each passing-w, under a dedicated rule-installation budget rather than the 5s startup timeout. Batching them viaiptables-restorewas considered and dropped:-wplus the separate budget closes the correctness half, leaving a latency refactor that would trade per-rule failure attribution for one opaque batch failure.Follow-ups filed: #935 (slirp liveness during the run), #936 (
AF_VSOCKand other non-IP families), #937 (network.proxyenforced on LXC/bwrap-0.8 but cooperative on Seatbelt/WSLc).🔍 Validation
Automated:
cargo test -p bwrap_common— 112 pass, incl. 37 new tests covering endpoint validation (translation,::1, hostname/IPv6, port-0, legacy-schema untouched,builtinTestServerexemption), iptables backend selection, and supervisor behaviour. The supervisor tests execute the script under a realshwith stubbednsenter/slirp4netns— rule ordering, readiness-written-last, every rule position failing ⇒ no readiness, slirp dying early, and the exact ACCEPT set (which fails if a:53rule is ever added).cargo clippy -p bwrap_common --all-targets -- -D warnings,cargo fmt --check— clean.node scripts/versioning/validate-configs.js— 233 configs.Manual E2E on Ubuntu 24.04 (WSL2), unprivileged, no sudo:
tests/scripts/run_bwrap_network_proxy_test.sh— 8/8 pass. Thebubblewrap_network_proxy_egress_denied.jsoncase asserts seven properties in one run: the proxy is reachable over a raw socket first (a positive control, so a blocked direct connection is attributable to the DROP rule rather than a dead network), direct egress blocked, loopback still exempt,CAP_NET_ADMINdropped, an in-sandbox flush refused with a non-zero status, tampering ineffective, and the proxy still reachable at the end (so an offline host fails rather than passing vacuously).slirp4netns/bwrapprocesses after teardown.Every new invariant was checked non-vacuous by injecting the matching regression (
|| trueon a rule, a swallowed rule failure, readiness moved ahead of the rules, the backend check stubbed to pass) and confirming the test fails. The first two are caught only by the executing supervisor tests — the text-based ones stayed green against an unenforced sandbox.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes📋 Issue Type
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.