[LXC] Make firewall chain identity collision-free - #780
[LXC] Make firewall chain identity collision-free#780Darren Hoehna (dhoehna) wants to merge 4 commits into
Conversation
Two distinct containers could share one iptables chain, so tearing down the first flushed and deleted the second's chain and left it running with no egress filtering. The chain name was built by filtering the container name to `is_alphanumeric() || '-' || '_'`, taking 20 characters, and prepending `MXC-`. Three separate defects fell out of that: - Filtering is lossy, so `a.b` and `ab` both produced `MXC-ab`. - Truncation is lossy, so any two names agreeing on their first 20 sanitized characters produced one chain. - `char::is_alphanumeric` is Unicode-aware and `take(20)` counts chars rather than bytes, so 20 retained characters could reach 80 bytes. Identity now lives in a hash over the original name: `MXC-<slug>-<hash>`, where the hash is the leading 10 bytes of SHA-256 in lowercase base32 (16 characters, 80 bits) and the slug is at most 7 characters kept only so an operator reading `iptables -S` can guess the owner. The slug carries no identity, so neither filtering nor truncation can cause a collision. The 28-character ceiling was measured rather than inferred: `iptables -N` accepts 28 and rejects 29 with "chain name ... too long (must be under 29 chars)". 4 + 7 + 1 + 16 lands exactly on it, and every generated shape was confirmed accepted by both iptables and ip6tables. This addresses accidental collision only. It does not make chain names resistant to an adversary who chooses container names: 80 bits gives a ~2^40 birthday bound, and the 28-character ceiling caps even a hash-only name near 120 bits. Adversarial ownership needs a persisted, verified ownership record, which is left for separate work rather than assumed away here. `sha2` was already in Cargo.lock transitively, so promoting it to a direct dependency adds no new package; the lockfile change is a single edge. Two existing tests encoded the defective contract and were updated: `chain_name_sanitization` asserted `MXC-my-container_123`, and `chain_name_truncation` asserted a 24-character cap. Six teardown fixtures hardcoded literal chain names and now derive them. Verification: 115 lib tests and 21 new black-box spec tests pass, clippy is clean at `-D warnings`, and fmt is clean. The spec tests were written by agents that had not read the implementation, then checked with 17 mutations: 14 were caught. The 3 survivors are equivalent mutants -- a base32 loop bound that is byte-identical at the fixed 10-byte width, a tail-emit branch that is dead because 80 mod 5 is 0, and hashing the reversed name, which is a bijection and so preserves injectivity exactly. 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
Makes LXC/Bubblewrap firewall chain names ASCII-safe, length-bounded, and collision-resistant.
Changes:
- Adds SHA-256/Base32 chain identity with readable slugs.
- Exposes chain names for validation.
- Adds extensive black-box tests and updates dependencies.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/Cargo.toml |
Adds workspace SHA-256 dependency. |
src/Cargo.lock |
Records direct LXC dependency. |
src/backends/lxc/common/Cargo.toml |
Enables SHA-256 for LXC common. |
src/backends/lxc/common/src/network_iptables.rs |
Implements hashed chain naming and updates tests. |
src/backends/lxc/common/tests/chain_name_spec.rs |
Adds chain-name specification tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // The hash is taken over the *original* container name and SHA-256 is | ||
| // byte-exact, so two names that differ only in letter case are distinct inputs | ||
| // and must not share a chain -- even when the differing letter lies past the | ||
| // 7-char slug, where the slug alone can no longer tell them apart. | ||
| #[test] | ||
| fn names_differing_only_in_case_receive_distinct_chains() { |
There was a problem hiding this comment.
Added, and you were right that this was a real gap rather than a cosmetic one.
The PR description had claimed the surviving reversed-input mutation was uncatchable in principle, on the grounds that pinning a literal digest is forbidden by the black-box rule. That was wrong and I have withdrawn it. The rule forbids me writing such a test, having read the implementation -- not the test existing. A sub-agent that had not read network_iptables.rs wrote three known-answer tests, and re-running the mutation confirms the catch: seeding hash_reversed_name now fails three tests where it previously survived. The suite goes 21 -> 24.
The pinned digests agree three ways -- an independent Python oracle, a second oracle written by the agent barred from reading the implementation, and the shipped Rust -- and a reviewer since reproduced all 14 literals independently in Python.
Writing them also surfaced a defect this PR introduces. Four tests/scripts/run_lxc_network_*.sh scripts hardcode the pre-hash chain name. Three would fail loudly on an LXC host; run_lxc_network_invalid_cidr_test.sh would have gone on passing while asserting nothing, because its only chain assertion was a cleanup check against a chain the new derivation can never produce. None of it surfaces in CI, since all four skip without root and LXC. All four now derive the name from the run's own debug output and diff MXC-prefixed chains against a pre-run snapshot. Fixed in 52a331c and 2d9875c.
| /// RFC 4648 base32 alphabet, lowercased. Base32 packs 5 bits per character | ||
| /// against hex's 4, which buys four extra hash bits inside the 28-byte ceiling. | ||
| const BASE32_LOWER: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; |
There was a problem hiding this comment.
Fixed, and then fixed again -- the second correction is the one that matters, so it is worth stating what was wrong with the first.
You were right about the arithmetic: 16 base32 characters carry 80 bits where hex carries 64, a 16-bit difference, not four. My first fix said that, but justified the choice by claiming 20 hex characters would not fit under the 28-byte ceiling. That is false. MXC- plus 20 hex is 24 characters and fits comfortably. An independent reviewer caught it before merge.
The real constraint is the slug. MXC-, the slug, and the slug's separator take 12 of the 28 bytes, leaving exactly 16 for the hash, so hex could carry 80 bits only by giving up the slug entirely. The comment now says that. Fixed in 2d9875c.
Two review comments, plus a defect the second one led to. The BASE32_LOWER comment claimed base32 "buys four extra hash bits" over hex, which is wrong. Base32 packs 5 bits per character against hex's 4, so the 16-character hash field carries 80 bits where hex would carry 64, and hex would need 20 characters for the same 80 bits. The comment now says that. The second comment asked for known-answer tests. The PR description had claimed a name-reversing mutation was uncatchable in principle; that was wrong. chain_name_spec.rs gains three KATs pinning literal digests, and a mutation run confirms they catch it: seeding hash_reversed_name now fails three tests, where before it survived. The digests agree three ways -- an independent Python oracle, a second oracle written by an agent that was not allowed to read the implementation, and the shipped Rust. Writing those tests surfaced a defect this PR introduces. Four LXC network test scripts hard-code the pre-hash chain name, for example MXC-CLI-LXC-Network-Inva, which the new derivation can never produce. Three of them also grep for a programmed rule and so would fail loudly on an LXC host. run_lxc_network_invalid_cidr_test.sh is worse: its only chain assertion is a cleanup check against a chain that can no longer exist, so it passed while asserting nothing. None of this shows up in CI, because all four skip without root and LXC. The scripts now derive the chain name from the run's own debug output and assert its shape and length ceiling, rather than restating the derivation in bash and creating a third implementation to keep in sync. Cleanup is checked by diffing MXC-prefixed chains against a snapshot taken before the run, so a chain left behind by an earlier failed run is not blamed on this one. The slug composition rule was undocumented, and the new shape assertion depends on it, so chain_name_for now states it. Verified: cargo fmt, clippy -D warnings, 115 lib tests, 24 spec tests. The three shell helpers were extracted from the shipped script and exercised in WSL as root against real iptables -- 14 of 14, including detection of a deliberately leaked chain and correct non-blaming of a pre-existing one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/backends/lxc/common/src/network_iptables.rs:240
- This new collision model leaves several comments in this file stale: lines 60–61, 1155–1156, and 1251–1253 still say names truncate at 20 characters and can collide because of sanitization/truncation. Update those explanations to the new hash-collision threat model so the ownership safeguards are documented accurately.
/// The hash is taken over the *original* container name, so container names
/// that differ only in characters the slug drops, or only past the slug's
/// length, still receive different chains. Two names collide only if their
/// SHA-256 digests collide in the leading 80 bits.
tests/scripts/run_lxc_network_ipv6_cidr_test.sh:69
- The explicit “In scope” list names only
network_iptables.rs, the two Cargo manifests, and the new spec test, and the PR says no file outside that list changes. This file plus the other three LXC network scripts are outside that contract. Please either include these script updates in the declared scope and verification or remove them; as written, the frozen scope is inaccurate.
# List the MXC-owned chains a tool currently holds. The chain name is derived
# from a digest of the container name, so a hard-coded literal rots the moment
# that derivation changes, and a cleanup assertion naming a chain that can no
# longer exist passes while testing nothing. Matching the MXC- prefix stays
# correct across naming changes.
src/backends/lxc/common/src/network_iptables.rs:173
- “Unique” overstates this 80-bit truncated hash: the function is collision-resistant, not injective, and the new threat-model documentation explicitly allows hash collisions. Describe the field as collision-resistant so the API documentation matches the implemented guarantee.
This issue also appears on line 237 of the same file.
/// Chain name unique to this container, as built by [`chain_name_for`].
chain_name: String,
Both found by an independent reviewer dispatched against this change. assert_no_new_mxc_chains consumed the chain listing through a process substitution, whose exit status is not the loop's. A failed enumeration therefore read as zero chains and the assertion passed while verifying nothing -- the same vacuous-pass class this PR set out to remove, and a control run confirms the old form printed a clean result against a listing command that does not exist. The listing is now captured first, and a failure to enumerate is a test failure rather than a silent pass. The BASE32_LOWER comment claimed the 28-byte ceiling leaves no room for a 20-character hex hash. That is wrong: MXC- plus 20 hex is 24 characters and fits. Hex fails for a different reason -- MXC-, the slug, and the slug's separator take 12 of the 28 bytes, leaving exactly 16 for the hash, so hex could carry 80 bits only by giving up the slug entirely. The comment now says that. This is the second correction to this comment; the first fixed the bit arithmetic and left the fit claim wrong. Verified: cargo fmt, 115 lib tests, bash -n on all four scripts, and 16 of 16 on the extracted shell helpers exercised in WSL as root against real iptables, now including a control proving the old form passed silently and the new one does not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
The chain-name spec was written against the documented contract, but without
consulting the testing corpus that governs how these suites are meant to be
derived. Re-deriving the suite against it surfaced four gaps, each of which a
plausible mutant slips through:
- `slug_keeps_underscores` -- the contract lists `_` in the slug alphabet, but
every prior vector used only letters, digits, and `-`.
- `slug_preserves_ascii_letter_case` -- only the hash is documented as
lowercased, so an uppercase letter must survive verbatim into the slug. The
prior case tests all varied the hash, never the slug.
- `single_sluggable_char_yields_a_one_char_slug` -- the `{1,7}` upper bound and
the slug-less form were pinned; the lower bound was not.
- `every_output_matches_the_documented_integration_script_shape` -- four bash
integration scripts grep the chain name out of debug logs against
`^MXC-([A-Za-z0-9_-]{1,7}-)?[a-z2-7]{16}$`, which makes that shape a
client-visible contract. The suite checked fragments of it (prefix, charset,
hash alphabet, length) but never the composed shape those scripts depend on.
The shape matcher is hand-rolled rather than pulling in a regex dependency. It
checks `is_ascii()` before splitting the trailing 16 bytes, because a non-ASCII
input would otherwise panic on a char boundary rather than return false.
Mutation testing, not the green run, is the evidence these tests work. Four
mutants of the slug rule -- dropping `_` from the alphabet, lowercasing the
slug, an off-by-one on the length, and reversing it -- are all caught. The
first two are each killed by exactly one test, and it is one of the tests added
here, so both would have survived the previous 24-test suite.
Tests: 24 -> 28, all green; fmt and clippy clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7155573c-8938-4622-abf7-4594fb17eb3d
Scope contract — [LXC] Make firewall chain identity collision-free
Veto line: if the one-sentence scope below is wrong, say so and nothing else
needs reading.
Scope: make
NetworkIptablesManager's chain name a total, ASCII-safe,length-bounded, collision-resistant function of the container name — and
nothing else.
Problem
Observed, in
mainatsrc/backends/lxc/common/src/network_iptables.rs:185-199:Three defects, each independently sufficient to make two distinct containers
share one firewall chain:
a.bandabbothproduce
MXC-ab.sanitized characters produce the same chain.
char::is_alphanumericis Unicode-aware, andtake(20)counts chars,not bytes.
容器survives the filter, so 20 retained chars can be up to80 bytes.
Expected: distinct container names get distinct chains, and every chain name is
accepted by iptables.
Why it matters: a shared chain means one container's teardown flushes and
deletes another's chain, leaving the second running with no egress filtering —
fail-open. This is the same hazard class as the merged PR 724, one layer up.
Evidence
iptables -Non thishost accepts 28 and rejects 29 with "chain name … too long (must be under 29
chars)".
XT_EXTENSION_MAXNAMELENis 29 including the NUL.PR 633 are this one defect: threads Change the WXC projects to statically link the CRT. #11, Handle container creation and policy more cleanly #15, Bump minimatch from 9.0.5 to 9.0.8 in /sdk #16, Bump minimatch from 10.2.2 to 10.2.4 in /sdk #19, #24, and Add copyright header #35.
Design
slug— up to 7 characters of the original name, keeping only[A-Za-z0-9_-]. Present purely so an operator readingiptables -Scanguess which container a chain belongs to. It carries no identity.
hash— the first 10 bytes ofSHA-256(container_name), base32-lowercase,16 characters, 80 bits. Computed over the original bytes, never the
slug, so neither filtering nor truncation can cause a collision.
Identity lives entirely in the hash, so two names collide only on a hash
collision.
Threat model — stated, because it bounds the claim
This fixes accidental collision. It does not make chain names resistant to
an adversary who chooses container names: 80 bits gives a ~2^40 birthday bound,
and the 28-character ceiling means even spending the whole budget on hash
reaches only ~120 bits. Adversarial ownership needs a persisted, verified
ownership record — that is unresolved thread #19, and it is out of scope
here and called out as deferred rather than silently assumed away.
In scope
src/backends/lxc/common/src/network_iptables.rs— chain-name construction,plus a public accessor so the behavior is testable from outside the module.
src/backends/lxc/common/Cargo.tomlandsrc/Cargo.toml— promotesha2toa direct dependency.
tests/scripts/run_lxc_network_*.shscriptsthat hardcoded the old chain name. This is a scope increase over the frozen
contract, taken because those scripts are broken by this change — see
below. It is recorded here rather than made quietly.
Out of scope — deliberately
INPUT-chain coverage for host-local traffic (thread Grant AppContainer access to NUL device before process creation #26).state_aware.rs, the Node SDK, PTY, signal cleanup.Gates that broke
This section was wrong when the contract was frozen, and is corrected here
rather than quietly fixed. It predicted two failures. Seven tests failed.
Correctly predicted — these encode the defective contract, and updating them is
the point of the change:
chain_name_sanitization— assertedMXC-my-container_123chain_name_truncation— assertedlen() <= 24Missed — the contract claimed the
MXC-fresh/MXC-survivorteardown fixtures"construct their expectations from the manager, so they follow automatically."
They do not. They hardcode chain-name literals in their flush/delete
expectations, so all five broke:
teardown_flushes_and_deletes_the_chainteardown_is_idempotent_when_the_chain_is_absentteardown_reports_failure_when_delete_failsadopted_container_teardown_removes_only_its_own_chainchain_names_have_mxc_prefix_and_total_length_cap_of_twenty_fourFixed with one
flush_and_deletehelper that derives the expected commandsfrom the manager, collapsing six duplicated literal pairs into one call site
each — rather than six separate hand-edits, which would have been churn for no
structural gain.
Dependency note
sha2is already inCargo.locktransitively (0.10.9 and 0.11.0), as aredigest,hex, andblake3. Promotingsha2 = "0.10"to a direct dependencyadds zero new packages to the supply chain.
Which invariant wins
When these conflict, in this order:
name the kernel rejects is worse than a collision.
A defect this PR introduced, found while addressing review
Changing the derivation broke four shell test scripts that hardcoded the old
chain name. None of it surfaced in CI, because all four
skip(exit 77)without root and LXC, so only a real LXC host would have caught it.
run_lxc_network_cidr_boundary_test.shMXC-CLI-LXC-Network-CIDRrun_lxc_network_dualstack_test.shMXC-CLI-LXC-Network-Dualrun_lxc_network_ipv6_cidr_test.shMXC-CLI-LXC-Network-IPv6run_lxc_network_invalid_cidr_test.shMXC-CLI-LXC-Network-InvaThe last one is the dangerous one. Its only chain assertion was a cleanup
check,
iptables -S "MXC-CLI-LXC-Network-Inva", against a chain the newderivation can never produce — so it would have gone on passing while asserting
nothing at all.
The fix does not reimplement sha256 and base32 in bash, which would be a
third implementation to keep in sync. Instead:
--debugoutput, thenasserted for shape (
MXC-<slug>-<hash>) and for the 28-character ceiling, soa malformed name still fails.
MXC--prefixed chains against a snapshot takenbefore the run, so a chain left behind by an earlier failed run is not
blamed on this one.
An independent review of that fix then caught a hole in it, recorded here
rather than quietly patched. The sweep consumed the chain listing through a
process substitution, whose exit status is not the loop's, so a failed
enumeration read as zero chains and passed — the same vacuous-pass class this
section exists to remove, reintroduced by the fix for it. A control run
confirms it: the first version reported a clean result against a listing
command that does not exist. The listing is now captured first, and failing
to enumerate fails the test.
Verification — actual results
cargo test -p lxc_common --lib— 115 passed (baseline parity withmain).cargo test -p lxc_common --test chain_name_spec— 28 passed. Writtenby sub-agents that had not read the implementation, per the
blackbox-unit-testsskill; I have read the file, so I could not authorthem.
holes. The suite was first written against the documented contract without
consulting the testing corpus that governs how these suites are derived.
Re-deriving it against that corpus added:
_retention in the slug, slugcase-preservation (only the hash is lowercased), the
{1,7}lower bound, andthe full composed shape
^MXC-([A-Za-z0-9_-]{1,7}-)?[a-z2-7]{16}$that thefour bash scripts grep for — the suite had checked fragments of that shape
but never the composed contract those scripts depend on. Mutants that drop
_from the slug alphabet or lowercase the slug are each killed by exactlyone test, and in both cases it is one of the four added here, so both would
have survived the previous 24-test suite.
cargo clippy -p lxc_common --all-targets --all-features -- -D warnings—clean.
cargo fmt --check— clean.investigated rather than assumed benign, and four turned out to be real holes
that are now closed:
base32_narrow_mask— silently halved entropy 80 → 64 bits; only 16 of the32 alphabet characters could ever appear.
base32_shift_wrong_way— collapsed entropy 80 → 30 bits, with 5 of 16positions entirely constant. The original injectivity test passed only by
luck (~19% collision probability at 20k samples). Missed because alphabet
coverage was asserted pooled across positions, and pooling hides a
constant position.
hash_lowercased_name—container-Aandcontainer-ashare the 7-charslug, so lowercasing before hashing gave them the same chain: exactly the
fail-open collision this change exists to prevent.
hash_reversed_name— an earlier revision of this description claimedthis mutant was uncatchable in principle. That was wrong, and the claim is
withdrawn. The stated reason was that catching it needs a pinned literal
digest, which the black-box rule forbids. The rule forbids me writing
such a test, having read the implementation — not the test existing. An
uncontaminated sub-agent wrote three known-answer tests, and a mutation run
confirms the catch: seeding
hash_reversed_namenow fails three testswhere it previously survived. The pinned digests agree three ways — an
independent Python oracle, a second oracle written by an agent barred from
reading the implementation, and the shipped Rust.
base32_off_by_one_bits(>=5→>5) — byte-identical across 3,000inputs; bit boundaries are data-independent at a fixed 10-byte width, so
identity on one input implies it on all.
base32_drop_tail_emit— 80 mod 5 = 0, sopending_bitsis always 0 afterthe loop and the tail branch is dead at this width.
shapes, including the 28-character maximum
MXC-abcdefg-qrstuvwxyz234567,were created and deleted with both
iptables -Nandip6tables -Nin WSL.All accepted, zero residue.
WSL as root against real
iptables— 16 of 16. That includes rejectingthe old
MXC-CLI-LXC-Network-CIDRformat, detecting a deliberately leakedchain, and correctly not blaming a pre-existing one. Two of those 16 are a
control for the silent-pass defect described above: run against a nonexistent
enumeration command, the old process-substitution form prints
PASSED-SILENTLYand the fixed form refuses. The scripts themselves stillcannot run here; they need root and LXC.
Microsoft Reviewers: Open in CodeFlow