[LXC] Fail closed when firewall rules cannot be scoped to the container - #790
[LXC] Fail closed when firewall rules cannot be scoped to the container#790Darren Hoehna (dhoehna) wants to merge 2 commits into
Conversation
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.
|
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
Fails closed when LXC firewall rules cannot be scoped to a veth interface.
Changes:
- Returns an enforcement error instead of silently skipping the FORWARD hook.
- Adds six tests covering failure, rollback, success, and no-op behavior.
- Review found a Bubblewrap regression from changing the shared manager.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/backends/lxc/common/src/network_iptables.rs |
Adds missing-veth failure behavior and test module. |
src/backends/lxc/common/src/network_iptables_veth_spec.rs |
Tests fail-closed and rollback semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return Err(format!( | ||
| "No veth interface for container; cannot scope iptables rules to chain {}. \ | ||
| The chain would never be reached from FORWARD, so the network policy would \ | ||
| not be enforced. Refusing to report success for an unenforceable policy.", | ||
| self.chain_name | ||
| )); |
|
Superseded by #798. This work was split into six PRs on my initiative; it should have been one. Four of the six (#790, #792, #796, #797) were cumulatively stacked, so each one re-rendered the previous diff rather than reducing what a reviewer had to read, and they forced a merge order for no benefit. All of the changes here are in #798, cut fresh from |
The problem
install_firewall_rulesbuilt the container's full deny-all chain and filled it with the network policy, then — when it did not know the container's veth interface — logged a warning and returnedOk(()).The chain is only ever reached from
FORWARDvia-i <veth>. With no hook, no packet traverses it. So the caller was told the network policy had been applied while zero packets were being filtered.That is worse than having no firewall, because it looks enforced. Nothing downstream had any way to tell the difference.
Why erroring is the right answer, and not the other two
There are exactly three things this code can do when it cannot scope the rules:
Ok(before)Err(this PR)Erroring loses no enforcement, because there was none to lose.
Blast radius — verified, not assumed
apply_firewall_rulesreturns early unless the enforcement mode isFirewallorBoth, andNetworkEnforcementModedefaults toCapabilities. A container that never wanted firewalling is untouched — and there is a test pinning exactly that.Errcorrectly.apply_firewall_rules_innerconverts it into a precise teardown of what was created plus residual adoption, andlxc_runner.rs:221destroys the container rather than starting a workload that believes it is confined. No new branch was needed.run_lxc_network_cidr_boundary_test.sh,..._dualstack_test.sh,..._invalid_cidr_test.sh,..._ipv6_cidr_test.sh) already fail if"FORWARD hook installed"is absent from the output. So in every environment where those pass today, a veth exists and this change is a no-op. It only alters the case those scripts already treat as a failure.Ok(())— all 115 still pass unchanged. That the fail-open was untested is itself the finding.Tests
Six black-box tests, written from the documented contract by an author who did not read the implementation, so they describe the intended behavior rather than mirroring whatever the code happens to do.
They pin the refusal under both firewall-requesting modes separately, the error naming the chain, the negative control (same policy succeeds once an interface is set — without which an apply that always returned
Errwould pass everything else), teardown ordering relative to chain creation, and theCapabilitiesno-op that bounds the blast radius.Mutation tested: 7 seeded defects, 7 caught by a failing test, 0 survivors. The seeds include restoring the old
Ok(()), dropping the chain name from the message, applying the check toFirewallbut notBoth, inverting the interface check, skipping rollback, and swallowing the error one layer up inrecord_apply_outcome. Each mutant is compiled with lints silenced, so a defect caught only by the compiler counts as a harness failure rather than a pass — the tests have to answer for themselves.The spec is attached as a
#[path]child module because the fake-firewall seam is#[cfg(test)]and private, which an integration test — a separate crate — can never reach.Scope
Slice 3 of the PR #632 re-cut (#632 was closed; its merge resolution had grown past the point of being reviewable). Follows #788 and #789.
Deliberately not fixed here: the second fail-open in the same file —
build_policy_rules_loggedwarns"could not resolve host"and continues, so underdefaultPolicy: allowablockedHostsentry that fails DNS silently becomes reachable. Four Linux E2E scripts grep that exact string and one requires it, so changing it needs those scripts updated in the same PR. It moves to the enforcement slice.Also noted and not fixed: host lists are only enforced under
Firewall/Both, while the runner treats a non-empty host list as "needs network" — so under the defaultCapabilitiesmode a container withblockedHostsgets no firewall at all. That belongs with the enforcement work.Refs AB#62830341.
Microsoft Reviewers: Open in CodeFlow