Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d0705a0
[LXC] Scrub and apply proxy env vars through the shared helper
dhoehna Aug 8, 2026
b74f5bf
Correct the LXC client note: the integration is planned, not wired
dhoehna Aug 8, 2026
40514a6
[LXC] Pin the proxy hostname instead of rewriting the URL host
dhoehna Aug 9, 2026
764900b
Drop a dead bracket guard and document why unbracketing is load-bearing
dhoehna Aug 9, 2026
bf37b12
[LXC] Make an unpinnable proxy address unrepresentable
dhoehna Aug 9, 2026
445ea2e
[LXC] Update proxy address spec for the unpinnable-address fix
dhoehna Aug 9, 2026
91b3760
[LXC] Fail closed when firewall rules cannot be scoped to the container
dhoehna Aug 9, 2026
a3d82c0
[LXC] Spec the fail-closed contract for unscopeable firewall rules
dhoehna Aug 9, 2026
47e00a9
[LXC] Hook the firewall chain onto the bridge port so it actually fil…
dhoehna Aug 9, 2026
3a05f6a
[LXC] Spec the FORWARD hook contract and assert enforcement end to end
dhoehna Aug 9, 2026
792d1ae
[LXC] Make deny rules win over allow rules and fail closed on an unre…
dhoehna Aug 9, 2026
988b609
[LXC] Spec deny precedence and assert an overlapping allow cannot def…
dhoehna Aug 9, 2026
8595d5d
[LXC] Correct the network policy docs and make the E2E suite gate in CI
dhoehna Aug 9, 2026
4ed9d4d
[LXC] Set FORWARD to ACCEPT in CI so only MXC rules can block
dhoehna Aug 9, 2026
76eb242
Keep Bubblewrap startable when no veth exists to scope the chain to
dhoehna Aug 9, 2026
3dd4196
Cover the Bubblewrap veth declaration so deleting it fails a test
dhoehna Aug 9, 2026
c098239
Pin the negative case of the missing-veth accessor
dhoehna Aug 9, 2026
76c4c51
[LXC] Admit network.proxy for LXC and reject the forms it cannot reach
dhoehna Aug 10, 2026
d1da5d3
[LXC] Let a caller force lxc-attach to clear the inherited environment
dhoehna Aug 10, 2026
de6c96a
[LXC] Wire the proxy-env scrub into the production execution path
dhoehna Aug 10, 2026
8c6fa72
[LXC] Restrict egress to the proxy endpoint when one is configured
dhoehna Aug 10, 2026
a46ad0a
[LXC] Pin the proxy host inside the container before running the script
dhoehna Aug 10, 2026
8dd810d
[LXC] Add the deny-all-except-proxy integration test
dhoehna Aug 10, 2026
63974b7
[LXC] Document the cooperative-proxy posture
dhoehna Aug 10, 2026
f397020
[LXC] Scope the integration test's DNS claims to what FORWARD can see
dhoehna Aug 10, 2026
daac8ab
[LXC] Require a firewall enforcement mode for network.proxy
dhoehna Aug 10, 2026
cfd062c
[LXC] Fail closed when an allow rule can outrank an unresolvable deny
dhoehna Aug 10, 2026
aafb0de
[LXC] Claim FORWARD hooks before installing them, not after
dhoehna Aug 10, 2026
19ac107
[LXC] Clear a stale proxy host pin when a run pins nothing
dhoehna Aug 10, 2026
edb9d38
Merge remote-tracking branch 'origin/main' into user/dahoehna/lxc-net…
dhoehna Aug 10, 2026
c3d133f
[LXC] Derive the chain name in the specs instead of hard-coding it
dhoehna Aug 10, 2026
c7c6142
[LXC] Narrow the deny-precedence failure to a catch-all allow
dhoehna Aug 10, 2026
130b1c9
[LXC] Reject proxy URLs that carry credentials
dhoehna Aug 10, 2026
3de0f5b
[LXC] Stop staging /etc/hosts through a predictable /tmp file
dhoehna Aug 10, 2026
c00f941
[LXC] Fail closed on a proxy the enforcement mode will not enforce
dhoehna Aug 10, 2026
8e1cf62
[LXC] Carry the reply path for allowed outbound connections
dhoehna Aug 10, 2026
739d23f
[LXC] Run the E2E suite under the forward policy production has
dhoehna Aug 10, 2026
d983827
Revert "[LXC] Run the E2E suite under the forward policy production has"
dhoehna Aug 10, 2026
d93bf12
[LXC] Enforce the no-credential proxy invariant at the runner boundary
dhoehna Aug 10, 2026
221f8d1
[LXC] Redact the proxy URL in the diagnostics that run before the guard
dhoehna Aug 10, 2026
628b0b4
Treat an unreadable sysfs as bridged instead of as directly routed
dhoehna Aug 10, 2026
12eb8e9
Do not read a dangling interface symlink as directly routed
dhoehna Aug 10, 2026
97ff4b1
Pin the metadata asymmetry with dangling-symlink tests
dhoehna Aug 10, 2026
b657fc7
Find proxy credentials in an opaque URL, not just an authority one
dhoehna Aug 11, 2026
dad5aeb
Stop rewriting /etc/hosts when the read that feeds it failed
dhoehna Aug 11, 2026
9595b58
Cover the credentialed-proxy rejection from outside the process
dhoehna Aug 11, 2026
bfcbc82
Close three holes an independent review found in the credential guard
dhoehna Aug 11, 2026
7a9948b
Stop reading a port separator as a URL scheme
dhoehna Aug 11, 2026
b67f7ec
Refuse to rewrite /etc/hosts through a symbolic link
dhoehna Aug 11, 2026
d26d826
Stop the credential test from publishing the secret it guards
dhoehna Aug 11, 2026
6392ea0
[LXC] Stop leading whitespace from hiding a proxy credential
dhoehna Aug 11, 2026
5c057c6
[LXC] Stop the drift guard from printing the credentials it guards
dhoehna Aug 11, 2026
aef66b5
[LXC] Stop a backslash from hiding a proxy credential, and pin the gu…
dhoehna Aug 11, 2026
6309789
[LXC] Correct the return-path rule's safety claim with a measurement
dhoehna Aug 11, 2026
a4613e3
Judge a proxy URL by what can leak, not only by what a parser sees
dhoehna Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/lxc-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: LXC E2E Tests

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
lxc-e2e:
name: LXC-Exec Container and Network Policy
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- uses: actions/checkout@v6

- name: Setup Rust toolchain
run: rustup update stable

- name: Point cargo at the MxcDependencies feed
uses: ./.github/actions/setup-cargo-feed

- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: src

- name: Install LXC and firewall tooling
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
lxc lxc-templates lxc-utils iptables debootstrap uidmap bridge-utils

# A bridged veth only reaches the FORWARD chain while br_netfilter is
# delivering bridged packets to iptables. Without it the firewall rules
# install cleanly and never fire, so the network policy tests would pass
# against a firewall that filters nothing.
- name: Enable bridge netfilter
run: |
sudo modprobe br_netfilter
sudo sysctl -w net.bridge.bridge-nf-call-iptables=1
sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1

# GitHub-hosted runners ship Docker, and Docker sets the IPv4 FORWARD
# policy to DROP. That breaks these tests twice over.
#
# First, it breaks them outright. MXC hooks its chain on traffic leaving
# the container (`-i <veth>` / `--physdev-in <veth>`), 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. The connection times out and an explicitly allowed
# destination looks unreachable. Observed exactly that: DNS resolved,
# because dnsmasq on lxcbr0 is host-local and never traverses FORWARD,
# and then `wget: can't connect to remote host (140.82.116.5)`.
#
# Second, and worse, it would make the deny cases meaningless. Under a
# DROP policy a container with NO working MXC hook at all is also
# unreachable, so the enforcement and deny-precedence tests would report
# success against a firewall that filters nothing -- which is the precise
# bug this suite exists to detect, and the reason these tests carry
# positive controls.
#
# 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. A missing hook then
# shows up as an unexpected success and fails the deny case loudly.
# A narrower conntrack RELATED,ESTABLISHED rule would fix the reply path
# but leave the DROP policy, and with it the vacuous pass.
- name: Let the host forward, so only MXC rules can block
run: |
sudo iptables -P FORWARD ACCEPT
sudo ip6tables -P FORWARD ACCEPT
Comment on lines +74 to +77

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and I want to correct something I wrote in that comment rather than defend it.

The mechanism is as you describe. Both hooks are ingress-only -- -i <veth> (network_iptables.rs:410) and -m physdev --physdev-in <veth> (:436). A reply arrives with the external interface as input and the container's port as output, so it matches neither, falls through to the policy, and under Docker's DROP is dropped.

I checked the one thing that could have made this a test-harness artifact rather than a product defect: Ubuntu's lxc-net normally installs its own -i lxcbr0 -j ACCEPT / -o lxcbr0 -j ACCEPT into FORWARD, which would carry the reply path regardless of the policy. It does not happen here. The full FORWARD chain on the runner (LXC E2E run 31433788808) is:

-P FORWARD ACCEPT
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD

No lxcbr0 rules at any point in the run. So nothing but the policy itself is carrying replies, and on a production host with FORWARD DROP there is nothing at all -- allowedHosts and an off-host proxy are unusable, exactly as you say.

Where I was wrong. The comment at lxc-e2e.yml:72-73 dismisses the conntrack fix -- "would fix the reply path but leave the DROP policy, and with it the vacuous pass." That treats vacuity as an argument against the rule, when it is really a separate problem with its own solution, and you named it: assert the MXC hook actually matched. The hook's packet counter distinguishes "the deny was enforced by MXC" from "the deny was enforced by the policy," which is the control the ACCEPT policy was standing in for. With that check the conntrack rule and a non-vacuous deny case are not in tension, and my reason for rejecting it does not hold.

One design point I want recorded, because the obvious implementation is wrong. The MXC chain already contains -m state --state ESTABLISHED,RELATED -j ACCEPT as a base rule (:707), so hooking the return direction into the same chain looks like a one-line fix. It should not be done that way. The chain's remaining rules are destination-shaped for egress, so inbound packets that are not established would be evaluated against them -- and under defaultPolicy: allow the chain closes with ACCEPT. That would quietly create an inbound enforcement surface with the wrong semantics, which is the surface tracked separately by AB#62864412 and discussed in the FORWARD-vs-INPUT thread on this PR. The return path needs to be a standalone conntrack-scoped ACCEPT matching the container's port as output, mirroring the existing ingress pair (-o <veth> and --physdev-out <veth>), so it restores replies for flows the chain already permitted outbound and cannot widen policy: a flow only reaches ESTABLISHED if the egress direction was accepted.

Status: not fixed in this push, and I would rather say so than half-do it. The rule itself is small, but it needs four new ownership flags threaded through install, teardown, and the partial-failure rollback in both families -- that is the most delicate machinery in this PR, and the E2E half (drop the forced ACCEPT, re-run the deny cases under DROP, add the counter assertion) is the larger part. This is the next thing I pick up on this branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8e1cf62.

Two return-path rules per family, scoped to the container's own port and
confined by conntrack state:

-I FORWARD -o <veth> -m state --state ESTABLISHED,RELATED -j ACCEPT
-I FORWARD -m physdev --physdev-out <veth> -m state --state ESTABLISHED,RELATED -j ACCEPT

Both attachment forms, because the reply direction has the same
routed-versus-bridged split the ingress hooks do (network_iptables.rs,
build_forward_return_iface_rule_args and
build_forward_return_physdev_rule_args).

They deliberately do not jump into the MXC chain, even though the chain already
opens with an ESTABLISHED,RELATED accept at build_base_chain_rule_args and
that would have been a one-liner. The chain's other rules are -d <destination>
egress shapes, so inbound NEW packets would be tested against them and, under
defaultPolicy: allow, fall through to the chain's closing ACCEPT. That is an
inbound enforcement surface acquired by accident and with the wrong semantics --
the surface AB#62864412 covers.

Install failure warns rather than fails the apply. The ingress hook is what
confines traffic to the chain, so losing it fails open and stays fatal; a rule
that only ever ACCEPTs can at worst leave the container less connected.

Ownership is claimed before insertion, matching the fix in aafb0de: a fatal
signal between the kernel accepting the rule and the process recording it would
otherwise leak an ACCEPT naming a veth the kernel is free to reassign.

Thirteen tests, mutation-tested in both directions. Worth flagging that the
first pass of them was defective: the rule builders are family-agnostic, so an
IPv4 rule and an IPv6 rule differ only by which binary issued them, and my
assertions ignored the binary -- two mutations survived because whichever family
still worked satisfied the assertion. They now pin the tool.

The harness half of your comment is not done and I have said so in the PR
description rather than closing it here: -P FORWARD ACCEPT still needs to come
out of the workflow, with the hook packet-counter assertion you named, so the
deny cases cannot pass vacuously. Also unmeasured: whether --physdev-out
actually matches bridged return traffic on a live container. I measured
--physdev-in at 11 packets against 0 for the interface form; I have not
measured the reverse.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my previous reply on this thread. I said the return path was fixed
in 8e1cf62. I then ran the experiment and it is not, on the topology that
matters.

I pushed 739d23f to drop -P FORWARD ACCEPT and run this suite under the
policy production actually has. The enforcement allow case failed:
MXC_NET_BLOCKED for a destination the policy explicitly allows, with both
return rules installed and no warning logged. So they install and still do not
carry the reply.

iptables-extensions(8) explains it. --physdev-out is "Name of a bridge port
via which a packet is going to be sent (for bridged packets entering the
FORWARD and POSTROUTING chains)". A reply from the internet arrives on the
host's uplink and is routed toward lxcbr0; the bridge port has not been
selected when FORWARD runs, so the physdev form cannot match. And -o <veth>
cannot match either, because the routing output device is lxcbr0, not the
veth. The asymmetry with the ingress side is real: --physdev-in matches
because the packet demonstrably arrived on the veth, which is why the ingress
hooks measured 11 packets against 0.

I have reverted the workflow change (d983827) rather than leave this suite red
for a reason I already understand. The rules from 8e1cf62 stay: they are
correct for a veth the host routes directly, and inert on a bridge. I have
rewritten the PR description to say exactly that -- it no longer claims this is
fixed.

The scoping that can work is the container's address rather than its port,
since the reply is identified by where it is going, not by a bridge port that
does not exist yet at FORWARD time. The address is already discovered in
lxc_runner.rs::wait_for_network, which today logs it and discards the value.
Plumbing it through to the manager, plus a live bridged measurement, is the
next change, and I would rather do it in its own PR than add a third untested
rule to this one.

One more thing worth recording, since it bears on your original point about
vacuity: I audited the scripts, and the suite is not vacuous under either
policy. Every script that asserts a block also asserts a reachability in the
same run -- the allow cases in run_lxc_network_enforcement_test.sh and
run_lxc_network_deny_precedence_test.sh, and proxy reachability in
run_lxc_network_proxy_test.sh. That pairing is what caught this. The
remaining network scripts assert programmed rule shapes and log lines, which do
not depend on the forward policy at all.

sudo iptables -S FORWARD | head -5

- name: Report the environment these tests depend on
run: |
echo "--- kernel ---"
uname -a
echo "--- lxc ---"
lxc-create --version || echo "MISSING lxc-create"
echo "--- iptables ---"
sudo iptables --version || echo "MISSING iptables"
sudo ip6tables --version || echo "MISSING ip6tables"
echo "--- forward policy (must be ACCEPT, or deny cases pass vacuously) ---"
sudo iptables -S FORWARD | head -1
sudo ip6tables -S FORWARD | head -1
echo "--- bridge netfilter ---"
cat /proc/sys/net/bridge/bridge-nf-call-iptables || echo "MISSING bridge-nf-call-iptables"
cat /proc/sys/net/bridge/bridge-nf-call-ip6tables || echo "MISSING bridge-nf-call-ip6tables"
echo "--- host ipv6 ---"
cat /proc/net/if_inet6 || echo "no /proc/net/if_inet6 (IPv6 disabled)"

- name: Build lxc-exec
working-directory: src
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: cargo build --release -p lxc --bin lxc-exec

# MXC_LXC_TESTS_REQUIRE_EXECUTION turns an honest skip into a failure.
# On a developer box a missing ip6tables is a reason to run what you can.
# Here the runner is provisioned specifically to execute this suite, so a
# skip means a prerequisite disappeared and the gate would go green while
# testing nothing.
- name: Run LXC E2E suite
env:
MXC_LXC_TESTS_REQUIRE_EXECUTION: "1"
run: sudo --preserve-env=MXC_LXC_TESTS_REQUIRE_EXECUTION bash tests/scripts/run_lxc_all_tests.sh

- name: Show leftover firewall state on failure
if: failure()
run: |
echo "--- FORWARD chain ---"
sudo iptables -S FORWARD || true
sudo ip6tables -S FORWARD || true
echo "--- MXC chains ---"
sudo iptables -S | grep -E '^-N MXC-' || echo "none"
sudo ip6tables -S | grep -E '^-N MXC-' || echo "none"

- name: Upload logs on failure
if: failure() || cancelled()
uses: actions/upload-artifact@v6
with:
name: lxc-e2e-logs-${{ github.event.pull_request.number || github.run_number }}
retention-days: 7
path: |
logs/
**/*.log
81 changes: 77 additions & 4 deletions docs/lxc-support/lxc-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,49 @@ Network policies are enforced with parallel `iptables` and `ip6tables` chains sc
| `defaultPolicy: "block"` | Final DROP rule in the container chain |
| `defaultPolicy: "allow"` | Final ACCEPT rule in the container chain |
| `allowedHosts` | ACCEPT rules for IP literals, CIDR blocks, or resolved hostnames |
| `blockedHosts` | DROP rules for IP literals, CIDR blocks, or resolved hostnames |

`allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Entries whose CIDR prefix is out of range for its family (or otherwise malformed) are reported as unresolved and skipped, leaving the rest of the policy in force. Host-list rules match all ports and protocols; port- and protocol-specific egress rules are not supported.
| `blockedHosts` | DROP rules for IP literals, CIDR blocks, or resolved hostnames, emitted *before* the ACCEPT rules |

**A deny wins over an overlapping allow.** `iptables` evaluates a chain top to
bottom and stops at the first match, so precedence is decided purely by
emission order. All `blockedHosts` rules are emitted ahead of all
`allowedHosts` rules, which means a destination named by both lists is dropped.
Without that ordering an allow entry broad enough to cover a blocked
destination — `0.0.0.0/0`, or a CIDR containing the blocked address — silently
defeats the block, and the resulting chain looks fully populated while
filtering nothing.

Two limits on that guarantee are worth stating plainly, because "deny always
wins" is not true without them:

- **DNS is exempt.** The base chain accepts UDP and TCP destination port 53
unconditionally and is installed ahead of the generated policy rules, so
port-53 traffic to a blocked destination is accepted before its DROP rule is
reached. Narrowing that rule needs to know which resolver addresses are
legitimate, and no schema field carries them today.
- **A hostname in both lists is resolved twice.** Each list entry is resolved
independently, so a name behind round-robin DNS can return one address for
the `blockedHosts` entry and a different one for the `allowedHosts` entry.
The guarantee holds for *addresses*, not for names. Use literal IPs or CIDRs
when a destination must be denied deterministically.

`allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Host-list rules match all ports and protocols; port- and protocol-specific egress rules are not supported.

An entry that resolves to nothing — an unknown hostname, or a CIDR prefix out
of range for its family — cannot be turned into a rule. What that costs
depends on the entry and on `defaultPolicy`:

| Entry | `defaultPolicy` | Behavior |
|-------|-----------------|----------|
| `allowedHosts` | either | Reported as unresolved and skipped. Failing to write an ACCEPT rule can only make the policy more restrictive |
| `blockedHosts` | `block` | Reported as unresolved and skipped. The closing DROP already denies the destination, so the unwritten rule was redundant |
| `blockedHosts` | `allow` | **Fails firewall setup.** The chain ends in ACCEPT, so the unwritten DROP was the only thing that would have denied that destination, and skipping it silently converts a deny into an allow |

One gap remains open and is not detected: under `defaultPolicy: "block"`, an
`allowedHosts` entry broad enough to cover a destination whose `blockedHosts`
rule went unwritten still reaches that destination. Detecting it would require
the address the failed entry was *meant* to resolve to, which is by definition
unavailable, so no check over the policy text can be complete — and a partial
check would imply a guarantee this code cannot make.

Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6tables -S` and classifies the result three ways:

Expand All @@ -130,7 +170,40 @@ Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6t

Host IPv6 activity is read from `/proc/net/if_inet6`: a non-loopback interface with an IPv6 address counts as active, while loopback-only `::1` on `lo` (present even on IPv4-only hosts) does not. If that file cannot be read at all — as opposed to being absent, which means IPv6 is disabled — the state is treated as *unknown* rather than as a confirmed "IPv6 is off", so an unreadable IPv6 state fails closed instead of leaving IPv6 unfiltered.

The chains are hooked into `FORWARD` for container egress by matching the host-side veth as the input interface. If MXC cannot discover the container veth, it skips the `FORWARD` hook with a warning rather than applying host-wide rules.
The chains are hooked into `FORWARD` for container egress with **up to two
rules per family**, because the input interface `FORWARD` sees depends on how
the veth is attached:

| Attachment | Rule that matches |
|------------|-------------------|
| veth routed directly by the host | `-i <veth>` |
| veth enslaved to a bridge (the default LXC topology) | `-m physdev --physdev-in <veth>` |

The two are mutually exclusive for any given packet, so nothing is counted
twice. Installing only `-i <veth>` is what previously let a fully populated
deny-all chain sit in the ruleset filtering nothing on the default bridged
topology.

The `physdev` rule is required only on a bridged veth. On a directly routed
veth a host whose kernel lacks the `physdev` match logs a warning and
continues with the interface rule alone, which is the rule that matches there;
on a bridged veth the same failure is fatal, because `physdev` is the only
rule that could ever match.

A bridged veth additionally requires `br_netfilter` to be delivering bridged
packets to iptables. With `/proc/sys/net/bridge/bridge-nf-call-iptables` absent
or `0`, both hook rules install cleanly and neither ever fires. MXC reads that
file and **fails firewall setup** rather than reporting success for a chain
that could never be reached. When the IPv6 chain is programmed,
`/proc/sys/net/bridge/bridge-nf-call-ip6tables` is checked separately and to
the same standard.

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.

Firewall state is torn down automatically with best-effort removal of the `FORWARD` hooks and both per-container chains; there is no network-policy opt-out field. Setup failures after partial creation are rolled back before returning an error, so retries do not trip over leftover chains.

Expand Down
34 changes: 33 additions & 1 deletion src/backends/bubblewrap/common/src/bwrap_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ impl BubblewrapScriptRunner {
logger,
"Bubblewrap: applying iptables rules for host-level network filtering"
);
let mut mgr = NetworkIptablesManager::new(&container_name);
let mut mgr = build_firewall_manager(&container_name);
match mgr.apply_firewall_rules(&request.policy, logger) {
Ok(true) => {}
Ok(false) => {
Expand Down Expand Up @@ -495,6 +495,23 @@ fn needs_iptables_rules(request: &ExecutionRequest) -> bool {
uses_firewall && has_host_rules
}

/// Build the iptables manager for a Bubblewrap sandbox.
///
/// Unprivileged bwrap has no veth to scope a chain to: the sandbox either
/// shares the host network namespace or gets a private one, and neither yields
/// a host-side interface to match on (see `local_network_diagnostic` in
/// `bwrap_command`). A missing veth is therefore structural here, not a failed
/// lookup, so the manager is told not to fail closed on it. Without that, every
/// Bubblewrap sandbox requesting firewall enforcement would refuse to start.
///
/// This lives in its own function so the declaration is covered by a test;
/// inlined at the call site, deleting it broke nothing that any test could see.
fn build_firewall_manager(container_name: &str) -> NetworkIptablesManager {
let mut mgr = NetworkIptablesManager::new(container_name);
mgr.allow_missing_veth_interface();
mgr
}

/// Best-effort iptables cleanup. Called on both success and error paths.
fn cleanup_iptables(manager: &mut Option<NetworkIptablesManager>, logger: &mut Logger) {
if let Some(ref mut mgr) = manager {
Expand Down Expand Up @@ -640,6 +657,21 @@ mod tests {
}
}

#[test]
fn the_firewall_manager_tolerates_the_veth_bubblewrap_never_has() {
// bwrap never calls set_veth_interface, so the shared manager's
// fail-closed path would refuse every firewall-mode sandbox at startup.
// The manager this backend builds must therefore have declared the
// absence up front.
let mgr = build_firewall_manager("bwrap-cov");

assert!(
mgr.veth_scoping_is_optional(),
"Bubblewrap has no veth, so the manager it builds must declare that a \
missing one is expected -- otherwise firewall-mode sandboxes cannot start"
);
}

#[test]
fn validate_does_not_locally_gate_builtin_test_server() {
// The builtinTestServer gate moved to `wxc_common::validator::validate_common`
Expand Down
Loading
Loading