Uh oh!
There was an error while loading. Please reload this page.
[LXC] Enforce the deny-all-except-proxy network policy (model 2) - #798
Conversation
LXC did not scrub proxy environment variables from caller-supplied env, so a caller could point a sandboxed process at an egress path the network policy never authorized, or disable the cooperative proxy outright. Add `apply_proxy_env` to `wxc_common::proxy_env`, the LXC entry point. It delegates to `apply_cooperative_proxy_env` so LXC scrubs and sets exactly the same key set as Bubblewrap and WSLc rather than maintaining a parallel list that can drift. With the proxy disabled the vars are still stripped. It returns `true` unconditionally, including for an empty env: the return value tells the caller to emit `--clear-env`, and an empty vector must still stop `lxc-attach` inheriting the MXC host process environment, which carries both proxy vars and credentials. Add `FTP_PROXY`/`ftp_proxy` to `PROXY_ENV_KEYS`. Both spellings of every family are now present, and the doc comment records why the lower-case duplicates are kept. Tests are black-box integration tests in `tests/proxy_env_spec.rs`, written against the public API by an author who did not see the implementation. All 22 pass; 7 of 7 seeded mutants are caught with no survivors. This is slice 1 of the work previously attempted in PR 632, re-cut from main so each slice is reviewable on its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
The test module header described client (a) in the present tense, which read as though the LXC backend already calls `apply_proxy_env`. It does not: the helper has no call site yet, and `attach_run` still derives `--clear-env` solely from `env` being non-empty (`lxc_bindings.rs:90`). Record the divergence while it is cheap to see. `apply_proxy_env` returns `true` even for an empty env so the MXC host environment cannot leak into the container, whereas current code emits no `--clear-env` in that case and pins the behavior with a test at `lxc_bindings.rs:743`. The integration slice has to update both. Comment only. No assertion changed; the tests validate the helper contract, which is what they are for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Model 2 needs the sandbox and the firewall to agree on exactly one proxy endpoint. Otherwise the sandbox re-resolves the hostname itself and, under round-robin or split-horizon DNS, reaches an address the firewall never authorized. PR 632 solved this by rewriting the proxy URL's host to the resolved IP. Review rejected that (comment 3724788051): an `https://`-scheme proxy would then be contacted at an IP literal, so SNI and certificate validation fail unless the proxy certificate carries an IP SAN. Add `ProxyHostPin` and `ProxyAddress::host_pin` instead. These express the mapping as a hosts-file pin, so the hostname stays in the URL and TLS identity is preserved while the endpoint is still forced. `host_pin` returns `None` when the address is already an IP literal, because there is then nothing to resolve. `hosts_line` writes the address bare: a hosts file takes an unbracketed IPv6 literal, unlike a URL host component. Also fix `to_url`. It hardcoded `127.0.0.1` whenever no original URL was recorded, regardless of the actual address. That is reachable: `unix_proxy_coordinator.rs:234` builds a `ProxyAddress` from the configured bind address with no original URL, so a proxy bound to a non-loopback address reported an endpoint it was not listening on -- the same class of defect as the objection above. Every existing caller passes `127.0.0.1`, so their output is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation testing surfaced an equivalent mutant: deleting the
`starts_with('[')` early return from `bracket_if_ipv6` changed no
observable behavior. Verified why, rather than assuming the tests were
weak -- `IpAddr::from_str` rejects brackets, so `[::1]` already fell
through the catch-all arm unchanged and could never be bracketed twice.
The guard was dead code. Remove it and record the reason.
The mirror case is NOT dead, and mutation proves it: replacing
`Self::unbracket(&self.address)` in `host_pin` with the raw field
fails a test. Unbracketing there is what lets a bracketed IPv6 literal
be classified as a literal instead of pinned as though it were a
hostname. Say so in the doc comment, which previously described it as
mere normalization.
Comment and dead-code only. All 566 library tests and 19 spec tests
pass unchanged, and the seeded-mutant suite now runs 9 for 9 with no
survivors.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3dReview on PR 789 pointed out that ProxyHostPin's fields were public Strings, so a caller could set ip to "[::1]", to the empty string, or to text containing a newline, and hosts_line() would emit it verbatim. That is an injection into /etc/hosts: a newline ends the record and starts a second, unauthorized mapping. The type exists to guarantee the sandbox and the firewall agree on one endpoint, so a value that denotes two mappings defeats its whole purpose. The fields are now private and the address is an IpAddr, so no such value can be constructed. IpAddr also renders IPv6 bare, which is what a hosts file requires -- the difference from to_url, which brackets, is now structural instead of a convention a caller has to remember. host_pin returns Result<Option<ProxyHostPin>, WxcError>. Ok(None) keeps its single meaning: the address is an IP literal, so there is nothing to resolve. An empty or malformed hostname is now Err, not None. Folding it into None would have told the caller "no hosts entry required", so a malformed address would silently skip the pin and let the sandbox re-resolve the name -- failing open, which is the defect review objected to elsewhere in this work. Tests are updated in a separate commit by the author who did not write this implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
22 black-box tests against the new host_pin contract, written by an author who has not read models.rs. The empty address moved from Ok(None) to Err, so the test that covered it was rewritten to match on all three arms by name. Asserting is_err() || is_none() would have passed either way, and the whole point of the change is that those two answers are not interchangeable: Ok(None) tells the caller no hosts entry is needed, which is how a malformed address ends up unpinned and the firewall bypassed. Added coverage for the injection strings review called out -- a hostname carrying a newline or a space must be Err and must never reach hosts_line. Dropped the test that stripped brackets from the ip argument; ip is an IpAddr now, so there is no textual form to strip and the behavior no longer exists. Mutation harness: 11 mutants, 11 caught by a failing test, 0 survivors. Four of them removed the last call to a private helper, which the crate's deny-warnings turns into a build failure -- real detection, but by the compiler, which proves nothing about the tests. The harness now suppresses those lints for the mutated build so the suite has to answer for itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
install_firewall_rules built the full deny-all chain and then, when no veth interface was known, logged a warning and returned Ok(()). The chain is only ever reached from FORWARD via `-i <veth>`, so without that hook nothing traverses it: the caller was told the network policy was applied while zero packets were filtered. That is the worst of the three possible outcomes. Installing the rules host-wide instead would at least filter, but unscoped they would hit every container and the host's own traffic. Returning an error loses nothing, because there was no enforcement to lose. This path is only reachable when the caller explicitly asked for firewall enforcement -- apply_firewall_rules returns early unless the mode is Firewall or Both, and NetworkEnforcementMode defaults to Capabilities. So the change cannot affect containers that never wanted a firewall. Rollback and teardown already handle the Err: apply_firewall_rules_inner converts it into a precise teardown of exactly what was created plus residual ownership, and lxc_runner destroys the container rather than starting a workload that believes it is confined. No existing test pinned the old behavior (115/115 still pass), which is itself the point: the fail-open was untested. The four Linux E2E scripts that exercise firewall enforcement already require "FORWARD hook installed" in the output and fail without it, so veth discovery demonstrably succeeds there and this change is a no-op for every run that passes today. Slice 3 of the PR 632 re-cut. Refs AB#62830341.
Six black-box tests for apply_firewall_rules, written against the documented contract by an author who did not read the implementation, so they describe the behavior that was intended rather than mirroring whatever the code does. They pin: - refusal when the veth interface is unknown, under Firewall and under Both, separately, so a fix scoped to one enforcement mode cannot pass - the error names the chain left unenforced, so an operator has something to search for - the negative control: the same policy succeeds once an interface is set. Without it, an apply that always returned Err would pass every other test - teardown of the chain created before the refusal, asserted as ordering against the creation command rather than mere presence - Capabilities-only containers issue no firewall commands at all, which is what bounds this change's blast radius Mutation tested: seven seeded defects, all caught by a failing test, no survivors. The seeds include restoring the old Ok(()) fail-open, dropping the chain name from the message, applying the check to Firewall but not Both, inverting the interface check, skipping rollback, and swallowing the error one layer up in record_apply_outcome. Each mutant compiles with lints silenced, so a defect detected only by the compiler counts as a harness failure rather than a pass -- the tests have to answer for themselves. Attached as a #[path] child module because the fake-firewall seam is #[cfg(test)] and private, which an integration test -- a separate crate -- cannot reach. Slice 3 of the PR 632 re-cut. Refs AB#62830341.
…ters
The per-container chain was hooked into FORWARD with `-i <veth>` only. That
matches nothing whenever the veth is enslaved to a bridge, which is the
default LXC topology: the packet is bridged onto `lxcbr0` and then routed off
it, so FORWARD sees the bridge as the input interface and never the veth. The
chain was built correctly, populated correctly, hooked without error, and
traversed by zero packets.
Measured on a live container before this change, with `defaultPolicy: block`
and no allowed hosts: every counter in the chain read 0, the closing DROP
included, and a fetch from inside the container succeeded. Adding a counting
rule on the same traffic in the same FORWARD chain gave 11 packets for
`-i lxcbr0` against 0 for `-i <veth>`.
Install a second hook per family matching `-m physdev --physdev-in <veth>`,
which identifies the bridge port the packet entered on and so stays scoped to
one container -- matching the bridge itself would apply one container's policy
to every container sharing it. The two rules are mutually exclusive for any
given packet, so a directly routed veth is still carried by the `-i` rule and
nothing is counted twice.
Fail closed on the two conditions that would leave the chain unreachable
again, in the same voice as the missing-veth refusal: a bridged veth whose
`bridge-nf-call-{ip,ip6}tables` toggle is absent or 0, and a bridged veth
whose physdev hook will not install. On a directly routed veth the physdev
rule is redundant, so a kernel without the match warns instead of failing.
Teardown removes both forms, built from the same builders used at insertion so
a delete cannot drift from the insert it has to match, and the chain delete now
waits on both hooks because either surviving one still references the chain.
Verified on a live container: `defaultPolicy: block` with no allowed hosts
now blocks, the same policy with `api.github.com` allowed still reaches it,
all five network E2E scripts pass, and teardown leaves no FORWARD reference
and no chain behind.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3dTwo kinds of test, because the defect this slice fixes was invisible to both kinds the repository already had. The unit specs pin the four seams the hook is built from: the two rule-args builders, bridge-enslavement detection, and the bridge-netfilter toggle read. They are written against the documented contract by an author who did not read the implementation. The guarantees that matter most are that the physdev builder never collapses into an input-interface match, that it names one specific bridge port rather than a wildcard, that a delete specification differs from its insert only by the operation -- iptables deletes by full rule specification, so a drifted delete silently leaks the hook -- and that an absent bridge-netfilter toggle reads as inactive, never as safe. Mutation testing over nine seeded defects, including the exact bug this slice fixes: 9 caught, 0 survivors. The E2E script exists because unit tests cannot see the failure at all. Every existing network script asserts that the FORWARD hook was *installed*, which is a log line; the hook installed cleanly, named the right chain, and matched zero packets. So this script asserts the guarantee instead: a destination the policy does not allow must be unreachable from inside the container, and an explicitly allowed one must still be reachable. The allow case is not decoration -- a blocked-only assertion would also pass on a host with no working network, or on a change that broke egress outright. Verified in both directions on live containers. Against the fixed implementation the script passes. Against the implementation from the parent commit it fails on the deny case with "egress succeeded under a default-block policy with no allowed hosts", which is the regression it exists to catch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
…solvable block The per-container chain emitted allow-list rules before block-list rules, and iptables applies first-match-wins within a chain, so a destination named in both `allowedHosts` and `blockedHosts` was ACCEPTed. A code comment recorded that as interim behavior owned by AB#62830341. Emit the block list first so the deny wins. Emission order is the entire precedence mechanism -- there is no separate resolution pass -- so the comment now says that outright, because swapping the two iterators back would reverse the security semantics without failing to compile. A block entry that resolved to no address programmed no rule and logged only a warning. Where the chain ends in ACCEPT that is a fail-open: the unwritten deny rule was the only thing that would have stopped the traffic, and the apply still reported success. `build_policy_rules_logged` now returns `Result` and errors in exactly that case, so the caller rolls back the chains it created rather than leaving a policy it did not enforce. The error is conditioned on the default policy rather than raised for every unresolvable block entry. Where the chain ends in DROP, an entry that resolves to nothing is redundant rather than missing -- the closing rule already denies every destination the allow list did not name -- and erroring there would refuse to start containers whose blocklists name hosts that do not exist, which is the ordinary case. `tests/configs/lxc_network_test.json` blocks `evil.example.com` under `defaultPolicy: block`, and that name is NXDOMAIN. The two tests that pinned allow-before-block ordering are deleted rather than inverted. They asserted the contract this change replaces, and the replacement assertions belong to the `deny_precedence_spec` module, which is authored separately so that the tests proving this change correct are not written by its author. The family-split test kept its subject and gave up only its incidental dependency on rule sequence. Residual gap, documented in the code rather than papered over: under a DROP default, a sufficiently broad allow entry can still cover a destination whose deny rule went unwritten. Detecting that needs the address the entry failed to resolve to, so no predicate over the policy text can be complete, and a partial check would imply a guarantee this code cannot make. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
…eat a block The implementation commit changed emission order and made an unresolvable deny entry fatal under an accepting default. This commit is the evidence that both hold, written against the documented contract rather than against the code. Twelve unit tests in a new spec module, authored from headers only by an agent that never opened `network_iptables.rs`. The author that wrote the implementation cannot write its tests: a test derived from the implementation encodes that implementation's bugs as expected behavior and will pass forever without catching anything. The tests assert the contract, not the current output: - a destination in both lists is dropped, for IPv4, for IPv6, and with several entries in each list - every DROP is emitted before every ACCEPT, checked by index rather than by comparing against a fixed expected vector - an unresolvable blocked host errors under an accepting default and the error names the host - the same unresolvable blocked host does not error under a blocking default, because the closing DROP already denies it - an unresolvable allowed host never errors under either default - an unresolvable entry does not suppress a sibling entry's rule or log line - v4 and v6 destinations land in their own buckets, asserted by parsing each destination rather than by matching a known list, so the assertion cannot be satisfied by an implementation that happens to emit the expected values Mutation testing supplies the proof that these tests can actually fail. Nine mutants, each a mistake a person could plausibly make in this function: restore the old emission order, error on every unresolvable block entry, error on unresolvable allow entries, never error at all, invert the default-policy test, swap the jump targets, drop the warning line, leak IPv6 destinations into the IPv4 bucket, and omit the host name from the error. caught=9 survived=0 harness_bugs=0 source restored byte-identical: True Mutant 1 is the load-bearing one. Two tests pinning the old allow-before-block order were deleted in the implementation commit, and a deletion with no replacement would have dropped coverage silently while the suite stayed green. Killing mutant 1 proves the replacement exists. The end-to-end guard runs the real binary against a config whose allowed and blocked lists both contain `0.0.0.0/0` and `::/0`. Literal CIDRs rather than a hostname, because a hostname is resolved separately for each list entry and round-robin DNS could hand back different addresses for the allow and the deny, making the verdict depend on which address the fetch picked. The control config is load-bearing. It allows the same destination and blocks nothing, so it must come back reachable. Without it, a host with no egress at all would produce the same blocked verdict on the overlap case and look exactly like a pass. The guard was verified to discriminate by running it against the previous commit's binary: b9946e3 ACCEPT then DROP overlap MXC_NET_ALLOWED guard FAILS, exit 1 447f10f DROP then ACCEPT overlap MXC_NET_BLOCKED guard PASSES Same script, same host, same configs. The control passed in both runs, so the difference is the rule ordering and not a host that lost its network. Gates: 154 unit tests pass, clippy -D warnings clean, fmt clean, all seven LXC end-to-end scripts pass.
The documentation described a firewall that no longer exists. Slices 3, 4, and 5 changed what happens on a missing veth, how the chains reach FORWARD, and which rule wins when the two host lists overlap, and none of it was written down. Four claims were false against the code: - The policy table left precedence unspecified. It is now deny-wins, and the reason -- first match ends chain evaluation -- belongs in the doc, because the ordering is the whole mechanism. - Unresolvable entries were described as always "reported as unresolved and skipped, leaving the rest of the policy in force". That is now conditional: under an accepting default an unresolvable blocked host is fatal. - The FORWARD hook was described as matching the host-side veth as the input interface. That omits the `--physdev-in` bridge-port rule and the `br_netfilter` requirement, which is precisely the omission that let a populated deny-all chain filter nothing. - "If MXC cannot discover the container veth, it skips the FORWARD hook with a warning" was flatly wrong. That path returns an error and rolls back. An independent review caught three further overstatements in the first draft of this text, all of which were mine and all of which were the comfortable direction to be wrong in: - "A deny always wins" is not true. The base chain accepts UDP and TCP port 53 unconditionally and is installed ahead of the policy rules, so DNS to a blocked destination is accepted before its DROP is reached. Narrowing that needs to know which resolver addresses are legitimate and no schema field carries them, so the honest move is to document the exemption rather than imply a guarantee the chain does not provide. - A hostname appearing in both lists is resolved once per entry, so round-robin DNS can return an address for the allow that the deny never saw. The guarantee holds for addresses, not for names. This was already known -- it is why the deny-precedence E2E guard uses literal CIDRs -- and it still did not make it into the prose. - "Two rules per family" is not unconditional. On a directly routed veth a missing physdev match warns and continues, because the interface rule is the one that matches there. Only on a bridged veth is it fatal. The IPv6 bridge toggle is also checked separately and was not mentioned. ## CI `lxc-e2e.yml` runs the suite on a provisioned Ubuntu runner. Until now no workflow executed these scripts at all, which is much of how a firewall that filtered nothing shipped green: the assertions existed and nothing ran them. The workflow enables `br_netfilter` explicitly. Without it a bridged veth never reaches FORWARD, every rule installs cleanly, nothing fires, and the network tests pass against a firewall that filters nothing -- the exact failure they are supposed to detect. `MXC_LXC_TESTS_REQUIRE_EXECUTION` turns an honest skip into a failure. A developer box legitimately lacks ip6tables or LXC and should run what it can, so a skip stays a warning there. A runner provisioned specifically to execute this suite is different: a skip means a prerequisite disappeared, and without this the gate goes green while testing nothing. Verified by running the suite four ways: normal and strict with prerequisites present both pass, and strict with the binary removed exits 1 naming the six skipped tests rather than reporting success.
The first run of this workflow failed three tests, and the three were the positive controls doing exactly what they exist for. GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD policy to DROP. That broke the tests twice over. Outright: MXC hooks its chain on traffic leaving the container, so an allowed request is accepted on the way out, but the reply arrives in the opposite direction, matches no MXC rule, falls through to the policy, and is dropped. DNS still resolved, because dnsmasq on lxcbr0 is host-local and never traverses FORWARD, so the symptom was a resolved address that then timed out: \wget: can't connect to remote host (140.82.116.5)\. IPv4 only, which matches Docker leaving the IPv6 policy at ACCEPT. And silently: under a DROP policy a container with no working MXC hook at all is equally unreachable, so the deny cases would have reported success against a firewall that filters nothing. That is the exact bug this suite exists to detect and the reason these tests carry positive controls. Without the controls this run would have been a green gate over a dead network. Setting the policy to ACCEPT restores the condition the tests were written for: the host forwards by default, so the only thing that can block container traffic is a rule MXC installed, and a missing hook fails the deny case loudly. A conntrack RELATED,ESTABLISHED rule would have fixed the reply path while leaving the vacuous pass in place, so it is the wrong fix. The environment step now prints both FORWARD policies, because a future runner image that reintroduces DROP would otherwise present as an unexplained timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
|
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
Strengthens LXC outbound firewall enforcement, deny precedence, proxy handling utilities, and E2E validation.
Changes:
- Adds veth-scoped FORWARD hooks and fail-closed enforcement.
- Emits deny rules before allow rules.
- Adds proxy utilities, tests, documentation, and LXC CI coverage.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
tests/scripts/run_lxc_network_enforcement_test.sh | Tests effective allow/block enforcement. |
tests/scripts/run_lxc_network_deny_precedence_test.sh | Tests deny-wins behavior. |
tests/scripts/run_lxc_all_tests.sh | Adds tests and strict CI mode. |
tests/configs/lxc_network_enforcement_deny.json | Defines default-deny case. |
tests/configs/lxc_network_enforcement_allow.json | Defines explicit-allow case. |
tests/configs/lxc_network_deny_precedence_overlap.json | Defines overlapping rules case. |
tests/configs/lxc_network_deny_precedence_control.json | Defines precedence control case. |
src/core/wxc_common/tests/proxy_env_spec.rs | Tests proxy environment hygiene. |
src/core/wxc_common/tests/proxy_address_spec.rs | Tests proxy URL and host-pin behavior. |
src/core/wxc_common/src/proxy_env.rs | Adds LXC proxy environment helper. |
src/core/wxc_common/src/models.rs | Adds proxy host pinning model. |
src/backends/lxc/common/src/network_iptables.rs | Implements hooks, precedence, and fail-closed behavior. |
src/backends/lxc/common/src/network_iptables_veth_spec.rs | Tests missing-veth handling. |
src/backends/lxc/common/src/network_iptables_forward_hook_spec.rs | Tests FORWARD hook construction. |
src/backends/lxc/common/src/network_iptables_deny_precedence_spec.rs | Tests ordering and resolution failures. |
docs/lxc-support/lxc-backend.md | Documents updated firewall semantics. |
.github/workflows/lxc-e2e.yml | Adds LXC E2E workflow. |
Suppressed comments (1)
src/backends/lxc/common/src/network_iptables.rs:1291
- The IPv6 physdev hook has the same signal window:
ip6tables -Ican succeed beforev6_physdev_hookis recorded and published, leaving the watchdog unable to remove the live hook. Publish pending ownership before the insert and distinguish an absent rule from a failed removal during rollback.
bridged,
"ip6tables",
logger,
)?;
Self::publish_created(created);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Slice 3 made a missing veth fatal: install_firewall_rules returned Err so a container could never start believing it was confined by a chain that FORWARD never reaches. That is right for LXC, which always names a veth once the container is running, so arriving at rule installation without one means the lookup lost it. Bubblewrap has no veth at all. Unprivileged bwrap either shares the host network namespace or gets a private one, and neither yields a host-side interface to match on -- bwrap_command.rs says so directly. bwrap_runner builds a NetworkIptablesManager and never calls set_veth_interface, so every Bubblewrap sandbox requesting Firewall or Both mode with host rules hit the new Err and failed to start. On main that path logged a warning and continued. No test covered it, so all six CI workflows stayed green. Make the strictness a property the caller declares. The default still fails closed, so both veth-spec tests and the LXC contract are unchanged. Bubblewrap calls allow_missing_veth_interface and keeps the pre-existing warn-and-skip, which leaves its policy unenforced -- a real gap, but a pre-existing one that belongs to Bubblewrap's own work item rather than to this LXC change. Adds three tests: the declared-missing case must succeed under Firewall and Both, and a manager that never declared it must still fail closed, so the two behaviors cannot collapse into one. Found by an independent reviewer auditing whether pre-existing tests needed to change; the regression was invisible because bwrap_common was never in the packages this branch had been testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation M4 -- delete the allow_missing_veth_interface call from bwrap_runner -- survived the whole suite. That is the same blind spot that let the regression land: the declaration lived inline in a 300-line execute function where no test could reach it. Extract build_firewall_manager so the declaration has a seam, and assert on it via a new veth_scoping_is_optional accessor rather than by standing up a real firewall -- lxc_common's fake-firewall seam is cfg(test) and so is invisible to bwrap_common. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Mutation M5 -- make veth_scoping_is_optional always return true -- survived, so the Bubblewrap suite would have passed on an accessor that could not say no. Assert a fresh manager reports false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Review found the IPv4 and IPv6 hook paths were the same forty lines twice with tokens substituted, over code that decides whether a container's egress is filtered. A fix applied there had to be applied by hand in both places or it silently covered one family. install_family_hooks and teardown_family_forward now carry both, parameterized by the tool, the sysctl that governs bridged delivery, and which half of the ownership record they own. Ownership was ten flat booleans that every emptiness, rollback, and teardown path had to enumerate by hand. Nesting them as two FamilyResources is what makes writing the body once possible at all. A resolved proxy answer was unbounded, and every address became its own ACCEPT rule and its own iptables process on the container-start path. It is capped now. Trimming fails closed and the pin is built from the first address, so the container still reaches the proxy it was pinned to. The in-file test named for a warning it never asserted is gone; the spec covers the same shape and does assert it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
derive_chain_name was copy-pasted into six scripts, mxc_chains into seven, and the same extended regular expression appeared six times. Nothing in bash tied those copies to each other, so a change to the hash width or the --debug output format had to be made in six places or it silently stopped matching in the ones that were missed. They now live in tests/scripts/lib/chain_name.sh, which each script sources. That removes 253 lines and adds 63, and the helper's derive_chain_name accepts both extraction patterns the copies had drifted into. The Rust meta-test that policed the copies shrank with them. Two of its five tests existed only to prove the copies agreed with each other: every_script_checks_the_same_documented_shape compared a check that now appears once, and a_script_that_derives_a_name_also_validates_its_shape asserted a pairing the helper makes structural. Both are vacuous against a single definition, and a test that cannot fail is worse than no test because it still has to be read and maintained. The three that remain check things a single definition does not: no_network_script_names_a_specific_chain still catches a hard-coded chain literal, the shape check now reads MXC_CHAIN_NAME_ERE out of the helper so the shell and Rust agreement is pinned in one place rather than six, and every_chain_asserting_script_derives_the_name_it_asserts_on still catches a script that stopped deriving. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/backends/lxc/common/src/lxc_runner.rs:285
- A reused container with no current hostname proxy never enters this branch, so an
#mxc-proxy-pinleft by a prior preserved run remains active while the new script executes. The host builds the new firewall policy from fresh DNS, but the container can still resolve through the stale address and bypass a matching deny. Clear any old marker before executing on a reused container whenproxy_host_pin()isNone, and treat a failed clear as fatal.
if let Some(pin) = fw_manager.proxy_host_pin() {
docs/lxc-support/lxc-backend.md:264
- This table says both host lists are simply omitted, but the implementation rejects any proxied policy with
blockedHosts(network_iptables.rs:1767-1773) while onlyallowedHostsis ignored with a warning. Documenting these as the same behavior makes a configuration described as valid fail at startup.
| Programs `allowedHosts` and `blockedHosts` | Programs neither | A block entry is redundant under the closing DROP, and an allow entry naming anything but the proxy contradicts the model |
Uh oh!
There was an error while loading. Please reload this page.
One of them, proxy_env_spec.rs, had lost a newline and was running two assert! statements together on a single line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
tests/scripts/run_lxc_network_proxy_hostname_test.sh:185
- This enables host-wide IPv4 forwarding but never restores its previous value. Running the E2E script locally can therefore leave the machine routing traffic after the test exits or skips. Snapshot the sysctl and restore it from the existing EXIT trap.
src/core/wxc_common/src/config_parser.rs:464 - The exact-name check accepts the canonical absolute form
localhost.(and the new characterization test explicitly locks that in). The host resolver can then return 127.0.0.1, which LXC pins inside the container; the proxy points at the container’s own loopback and is guaranteed to be unreachable despite passing validation. Normalize a single DNS trailing dot before this check, and update the characterization test to require rejection.
fn host_is_loopback(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let candidate = crate::models::unbracket_host(host);
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
An `-I` that reports failure may still have landed: a command killed after the kernel accepted its rule reports failure too. The code then cleared ownership, which stranded an ESTABLISHED,RELATED ACCEPT on a veth name the host is free to hand to another container. The chain hooks already closed this window through install_claimed_hook; the return rules did not. install_return_rule now takes the builder rather than a built rule, so the removal it attempts on failure cannot drift from the insert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
src/backends/lxc/common/src/lxc_runner.rs:285
- When no pin is produced (no proxy, or an IP-literal proxy), this path never removes a marker left by an earlier interrupted/failed run on a reused container. This contradicts
build_hosts_unpin_command's stated requirement that a run which pins nothing clean up stale pins, and lets a mapping outlive the policy that authorized it. Run the unpin operation before executing whenever there is no current hostname pin, treating failure as fail-closed.
let mut pinned = false;
// A proxied chain opens no port 53, so without this the container has
// no resolver to find its proxy with.
if let Some(pin) = fw_manager.proxy_host_pin() {
src/backends/lxc/common/src/lxc_runner.rs:374
- Downgrading unpin failure to a warning tears down the firewall while leaving a persistent container running with the old policy-owned hosts mapping. That violates the promised pin lifetime and makes the next run inherit an unauthorized address. For a container that will survive this run, stop it or fail the operation until the pin is removed; do not report ordinary success while removing the corresponding chain.
// The script has already run, so a failure here cannot change its
// result and must not replace it.
if let Some(reason) = unpin_error {
let _ = writeln!(
logger,
"Warning: failed to clear the proxy host pin: {}",
reason
);
}
src/backends/lxc/common/src/lxc_runner.rs:484
- This symlink check is vulnerable to the TOCTOU race documented immediately above it: a process retained in a reused container can replace
/etc/hostswith a symlink after-hsucceeds but before the privileged redirect opens it, causing MXC to truncate an attacker-selected container or writable host-mounted file. The hosts update needs an open-once implementation usingopenat/O_NOFOLLOW(inside the container mount namespace), rather than a shell check followed by a path reopen.
"if [ -h /etc/hosts ]; then \
printf 'mxc: refusing to rewrite /etc/hosts: it is a symbolic link\\n' >&2; \
exit 4; \
fi; \
Uh oh!
There was an error while loading. Please reload this page.
Three files conflicted, and all three were additive on both sides. tests/scripts/run_lxc_all_tests.sh and docs/lxc-support/lxc-backend.md keep both sides. The doc intro now states that both halves of network policy, outbound and inbound, need enforcementMode of firewall or both -- main described the two halves, this branch described the gate, and each is true of the other's half. lxc_runner.rs is the real one: main added the inbound ingress chain and this branch added the /etc/hosts proxy pin, at the same point in the run. Both are kept, ingress first, so inbound default-deny is installed before the pin runs a command inside the container rather than after it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
.github/workflows/lxc-e2e.yml:83
- The workflow makes the new allow/proxy paths pass by globally changing
FORWARDtoACCEPT, but the implementation documents that neither generated return rule matches replies on the default bridged topology (network_iptables.rs:689-696). On a host where Docker has setFORWARDtoDROP, requests leave through the MXC chain and every reply is dropped, so explicitly allowed destinations and the proxy are unusable. Please add a return-path rule that actually matches routed replies (for example, scoped to the container IP) and run this E2E case with the host's DROP posture instead of masking it.
- name: Let the host forward, so only MXC rules can block
run: |
sudo iptables -P FORWARD ACCEPT
sudo ip6tables -P FORWARD ACCEPT
docs/lxc-support/lxc-backend.md:219
- The new implementation and E2E test now preserve the egress chains when
preservePolicyis true, but the paragraph immediately below still says thatpreservePolicydoes not keep egress chains alive. Update the lifecycle documentation so it matches the behavior introduced here.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.
Installing the rules host-wide instead is not an option either: unscoped, they
would apply to every container and to the host's own traffic.
Uh oh!
There was an error while loading. Please reload this page.
The parser refuses one for both LXC and Bubblewrap, but the parser only sees requests it built. ExecutionRequest and ProxyAddress::from_url are public, to_url returns the URL verbatim, and build_args emits it as a bwrap --setenv argument -- so a programmatically built request put the password in /proc/<pid>/cmdline for any local user to read. LXC already carried this second guard at its runner boundary. This adds the matching one to Bubblewrap, with the input checks rather than after the bwrap probe, so a host with no bwrap installed is still told what is wrong with the request rather than what is wrong with the host. The two tests were written first and both failed with validate returning Ok on a URL carrying alice:hunter2@. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 42 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
src/backends/lxc/common/src/lxc_runner.rs:253
preserve_policyis enabled before setup succeeds. If a later iptables command fails and rollback also fails,record_apply_outcomeintentionally retains ownership soDropretries cleanup, but this flag suppresses that retry and strands the residual chain/hooks. Mirror the ingress path below: set preservation only after the full firewall apply has succeeded.
// Configure network rules
let mut fw_manager = NetworkIptablesManager::new(&container_name);
fw_manager.set_preserve_policy(!self.cleanup_policy);
src/core/wxc_common/tests/proxy_env_spec.rs:17
- This newly added contract description is already stale: LXC now calls
apply_proxy_env, the function returns(), and the runner always passesforce_clear_env=truetoattach_run. Describing the integration as planned and a boolean return value makes the test contract contradict the code it tests.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:
//! `apply_proxy_env` returns `true` even for an empty env so the host
//! environment cannot leak, whereas current code emits no `--clear-env`
//! then. Wiring this in must update `lxc_bindings.rs` and the test at
//! `lxc_bindings.rs:743` that pins the current empty-env rule. These tests
//! validate the helper contract, not existing LXC behavior.
docs/lxc-support/lxc-backend.md:219
- The following paragraph still says
preservePolicydoes not retain egress chains, but this PR addsNetworkIptablesManager::set_preserve_policyand an E2E test that requires the chain and FORWARD hook to survive. Update that statement so the backend documentation matches the new lifecycle behavior.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.
Installing the rules host-wide instead is not an option either: unscoped, they
would apply to every container and to the host's own traffic.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The loopback guard this PR added covers two backends, and only one of them should be covered. What makes a loopback proxy address wrong is which side of the namespace the proxy runs on, not that the literal is 127.0.0.1. LXC opens egress across the veth and pins the proxy's address into the container's /etc/hosts, so both assume an address routable from inside the container -- a loopback there is the container's own loopback, and the configuration cannot work. WSLc's url form means the opposite: the proxy runs inside the container, and loopback is the only address that both the client and a proxy hosted alongside it can name. The already-shipped tests/configs/wslc_network_proxy.json depends on exactly that, and run_wslc_proxy_test.ps1 says why -- the proxy is an in-container marker server. The host-run WSLc forms, localhost and builtinTestServer, are rejected by the separate guard just above this one and are unaffected. The regression survived review because the test written alongside it asserted the wrong contract: proxy_loopback_url_rejected_with_wslc encoded the rejection as intended behavior, so nothing was left to notice the working configuration had been broken. That test is replaced by proxy_loopback_url_accepted_with_wslc, which fails against the old guard with the rejection message it produced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
Removing the pin only at the end of a run that wrote one leaves the container carrying it in two ordinary cases: a run interrupted after pinning, and a run whose removal failed, since that failure is only warned about. The next run over the same container clears it only if that run also pins, because pinning rewrites /etc/hosts from a filtered copy and disposes of the old line on its way past. A run that configures no proxy has no such side effect, so it executed its script against a hostname resolving to an address that only some earlier policy authorized -- which is the case the doc comment on build_hosts_unpin_command already described, and the call site did not implement. A container this run did not create now has the pin cleared before the script runs. Doing it first is what makes it a guarantee rather than best effort: the end-of-run removal cannot help a run that never reaches its end. A clear that fails stops the run and returns an error, mirroring the pin-failure path directly above, because a failure here still changes what the script would resolve. Only reused containers pay the extra attach_run. run_lxc_network_proxy_reuse_test.sh grows a third run covering it: a pin is written into the surviving container's /etc/hosts by hand, standing in for the interrupted run, and a config with no proxy then runs against that container. The script inside reports whether it can see the pin, so the assertion is what the workload would actually resolve rather than what the host file looks like afterwards. Against the previous code it reports PIN_PRESENT_DURING_RUN. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/backends/lxc/common/src/lxc_runner.rs:253
preserve_policyis enabled before installation succeeds. If setup fails and rollback leaves residual resources (the explicitretain_residual_ownershippath), this early return drops the manager with preservation enabled, soDropwill not retry cleanup and the residual chain/hooks are stranded. Set preservation only in the successfulOk(true)arm, as the ingress manager already does below.
fw_manager.set_preserve_policy(!self.cleanup_policy);
tests/scripts/run_lxc_network_proxy_hostname_test.sh:185
- This changes the host-wide
net.ipv4.ip_forwardsetting but cleanup never restores its original value. Running the E2E suite locally on a host where forwarding was disabled leaves routing enabled after success, failure, or skip. Capture the original value before changing it and restore it in the existing trap.
src/core/wxc_common/tests/proxy_env_spec.rs:17 - This newly added client description is already stale: LXC is now wired in this PR,
apply_proxy_envreturns(), andlxc_runneralways passesforce_clear_env=true. As written, the test module documents an API and integration state that do not exist.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:
//! `apply_proxy_env` returns `true` even for an empty env so the host
//! environment cannot leak, whereas current code emits no `--clear-env`
//! then. Wiring this in must update `lxc_bindings.rs` and the test at
//! `lxc_bindings.rs:743` that pins the current empty-env rule. These tests
//! validate the helper contract, not existing LXC behavior.
docs/lxc-support/lxc-backend.md:219
- This section still says at line 219 that
preservePolicydoes not keep egress chains alive, but this PR now callsNetworkIptablesManager::set_preserve_policyand adds an E2E test requiring the chain and hook to survive. Update that stale lifecycle statement so operators know preserved host firewall state is intentional.
If MXC cannot discover the container veth at all, firewall setup **fails** and
the partially created chains are rolled back. An unhooked chain is never
traversed, so reporting success would hand the caller a deny-all chain that
filters nothing — strictly worse than no firewall, because it looks enforced.
Note for anyone re-reviewing: three threads on this PR defer a finding to #869, #870, or #875, and all three now show as closed. They were not dropped -- the backlog was consolidated from 41 issues into 8 area issues, and each original carries a comment naming its new home.
|
The word carries a meaning it does not need to carry, and nothing outside this branch used it -- all twenty occurrences arrived with this PR, so there is no existing usage to stay consistent with. Renames two tests and their sysfs fixture directories to match. The sysfs link is still `master`, because that is the kernel's name for it and not ours to change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0bdca3d7-e52b-4354-83ca-84eec7395f18
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (7)
docs/lxc-support/lxc-backend.md:304
- The implementation caps proxy DNS results at 16 and pins only the first result, so saying every resolved address is opened is incorrect for larger answers. Document the bound and selected pin to avoid promising connectivity the runner intentionally does not provide.
Every address the proxy host resolved to is opened, since they all belong to
that same proxy. If the hosts entry cannot be written, execution **fails**
src/backends/lxc/common/src/lxc_runner.rs:267
- Preservation is enabled before
apply_firewall_rulessucceeds. If setup fails after creating state and rollback leaves a residual,record_apply_outcomeretains ownership specifically soDropretries cleanup, but this flag makesDropskip that retry and strands the chain/hook. Enable preservation only in the successful arm, as the ingress manager already does.
fw_manager.set_preserve_policy(!self.cleanup_policy);
tests/scripts/run_lxc_network_proxy_hostname_test.sh:185
- This changes the host-wide IPv4 forwarding setting but cleanup never restores its original value. Running the test locally can therefore leave routing enabled after either success or failure; capture the prior sysctl value and restore it from the EXIT trap, as the bridge-netfilter test does for its global setting.
docs/lxc-support/lxc-backend.md:219 - This documentation now contradicts the runner and the new preserve-policy E2E test:
set_preserve_policydeliberately keeps successfully installed egress chains whenpreservePolicyis true. Document the new lifecycle behavior rather than saying there is no opt-out.
This issue also appears on line 303 of the same file.
Egress firewall state is torn down automatically with best-effort removal of the `FORWARD` hooks and both per-container chains; there is no egress network-policy opt-out field, and `preservePolicy` does not currently keep the egress chains alive. Setup failures after partial creation are rolled back before returning an error, so retries do not trip over leftover chains.
src/core/wxc_common/tests/proxy_env_spec.rs:17
- This newly committed test description is stale: this PR wires LXC to
apply_proxy_env, the helper returns(), andlxc-attachnow receives explicitclear_env=true. Keeping the old “planned” contract makes the test suite describe behavior that no longer exists.
//! (a) LXC backend (PLANNED integration, not yet wired) -- will call
//! `apply_proxy_env` and use the returned bool to decide whether to pass
//! `--clear-env` to `lxc-attach`. Today `attach_run` derives `--clear-env`
//! solely from `env` being non-empty (`lxc_bindings.rs:90`). The empty-env
//! case is where the helper contract and current behavior diverge:
src/core/wxc_common/tests/proxy_address_spec.rs:37
- The pin surface does have a caller in this PR:
LxcScriptRunnerobtainshost_pin()and writes/removes itshosts_line(). Update this test contract so future maintainers do not treat the live LXC integration as merely planned.
//! * The pin surface (`host_pin`, `hosts_line`, `ProxyHostPin`) still has no
//! callers. It is planned wiring for the firewall / hosts-file consumer, so
//! the tests below name that consumer as planned, not present. `ProxyHostPin`
//! has no public constructor -- the only way to obtain one is `host_pin` on a
//! hostname -- so the tests build pins that way through the `pin_for` helper.
src/backends/lxc/common/tests/chain_name_script_drift_spec.rs:53
run_lxc_network_preserve_policy_test.shalso asserts on the derived chain name, but it is omitted from this supposedly exhaustive list. As a result, the drift test will not fail if that new script later stops deriving the name; include it alongside the other chain-asserting scripts.
"run_lxc_network_invalid_cidr_test.sh",
"run_lxc_network_ipv6_cidr_test.sh",
];
Gudge (MGudgin)
left a comment
There was a problem hiding this comment.
Verified the updated implementation and mapped the prior findings against the current head. The final post-verification commit is terminology-only; targeted Rust tests and all current CI checks, including LXC E2E, pass. Remaining limitations are documented or tracked in follow-up issues.
Refs AB#62830341 — [LXC] Network policy model 2. The GA-blocked LXC network
work is tracked separately under AB#63505947.
Summary
Give LXC a deny-by-default outbound network posture, with exactly one exception:
a cooperative proxy.
Covers Linux roadmap N1 (default-deny outbound), N4 (deny-wins
precedence), N5 (proxy env vars and enforcement), and row 22 (proxy
env-var hygiene) for LXC — for forwarded egress only. See Not covered
below; the roadmap rows stay 🟡 Actionable. The N7 GA schema migration is
untouched.
What it does
enforcementModeoffirewallorboth,each container gets its own
MXC-<slug>-<hash>chain, hooked intoFORWARDwith
--physdev-inon the container veth. The physdev match is what makesthe rules fire at all: the veth is enslaved to
lxcbr0, so a plain-i <veth>match takes no packets. The default mode,capabilities,installs no rules.
determined, setup returns
Errand names the unenforced chain instead ofreporting success with a fully populated chain that nothing jumps to.
Rollback is best-effort: anything a removal command fails to delete stays
owned, so teardown retries it rather than stranding it. A rule whose insert
failed is deleted before its claim is released, so a partial failure cannot
leave behind a rule nothing owns. Bubblewrap has no host-side veth by
construction, so it declares that and keeps its existing warn-and-skip.
blocked_hostsare programmed ahead ofallowed_hosts, so anaddress named in both is dropped. The base port-53 allowance and the base
ESTABLISHED,RELATEDaccept precede both.case-insensitively, so a sandboxed process cannot override or disable the
proxy.
HTTP_PROXY,HTTPS_PROXY, andALL_PROXYare set to the configuredURL in both spellings, and
NO_PROXYis forced empty so an image-baked valueexempts nothing. In proxy mode the chain carries the proxy ACCEPTs and its
closing DROP and nothing else, and IPv6 egress is denied outright.
blockedHostscombined with a proxy is an error, not a warning: the proxy can fetch a
blocked destination on the container's behalf, so the block list would read as
programmed while enforcing nothing.
allowedHostsonly warns, because theproxy is strictly narrower than the allowance being asked for. A proxy
hostname resolving to more than 16 addresses is trimmed to the first 16, so a
round-robin answer cannot become an unbounded rule set — and an unbounded
number of
iptablescalls — on the container-start path.opens no port 53, so the proxy's address is written straight into the
container's
/etc/hostsas a marked line. A failed pin stops the containerand fails the run instead of executing the script against a proxy it cannot
reach. The line is removed at the end of the run that wrote it, so a
container reused across runs never inherits an address a later policy did not
authorize.
enforcementModeoffirewallorboth: undercapabilitiesno rules are installed, so theconfig would read as deny-all-except-proxy while enforcing neither half.
Loopback and built-in-test-server proxies are refused as well, since the
container's loopback is not the host's.
credentials is rejected, because both backends put it in a child process's
argument vector —
lxc-attach --set-varandbwrap --setenv— where anylocal user can read it out of
/proc/<pid>/cmdline. The parser guard onlycovers requests the parser built, and
ExecutionRequestis public, so LXC andBubblewrap each carry the same check at their runner boundary as well.
.github/workflows/lxc-e2e.ymlinstalls the LXC stack,enables
br_netfilter, and runs the enforcement suite against real containersand real iptables, with
MXC_LXC_TESTS_REQUIRE_EXECUTION=1so a skipped casefails instead of passing quietly.
Not covered
pinning
state-aware e2e
Validation
cargo test -p wxc_common -p lxc_common -p bwrap_common -p lxc— 1049 tests,0 failures.
cargo clippy --all-targets -- -D warningsandcargo fmt --check— clean.and real iptables.
All three were run on the merge head, after
origin/mainwas merged in.Microsoft Reviewers: Open in CodeFlow