Skip to content

network: isolate guest VMs from each other on the shared bridge - #452

Open
DarkaMaul wants to merge 2 commits into
mainfrom
network-isolate-tap-bridge-ports
Open

network: isolate guest VMs from each other on the shared bridge#452
DarkaMaul wants to merge 2 commits into
mainfrom
network-isolate-tap-bridge-ports

Conversation

@DarkaMaul

Copy link
Copy Markdown
Contributor

Firecracker instances on one host share br0 and a single subnet, so any guest
could reach any other guest's SSH and forwarded ports.

What this does

Two controls, both asserted on every setup_tap and both fail-closed:

  1. L2 — each TAP is marked an isolated bridge port, so the bridge will not
    forward frames between two guest ports. This is the only control that can
    cover this path: bridged frames reach the FORWARD chain only when
    br_netfilter is loaded.
  2. L3iptables -I FORWARD 1 -i br0 -o br0 -j DROP, removed in
    teardown_bridge alongside the other three rules. Port isolation cannot
    cover this path: a frame a guest addresses to the bridge is local delivery,
    not port-to-port forwarding, so the flag never applies, and ip_forward
    sends it back out br0 from the bridge device, which has no isolated source
    port either. Without it, ip route add <peer>/32 via <bridge ip> in a root
    guest reaches the peer on any host whose FORWARD policy is ACCEPT — the
    kernel default.

The rule is asserted per setup_tap rather than in ensure_bridge, which
returns early on a pre-existing bridge and would otherwise leave it absent.

Guest→host and guest→internet are unaffected: the DROP matches neither local
delivery nor br0 → host_iface.

Readback

bridge link set … isolated on can succeed while doing nothing.
IFLA_BRPORT_ISOLATED is attribute 33, and a kernel below 4.18 caps the
bridge-port policy at 32 and silently drops out-of-range attributes, so the
command exits 0 on a port that is not isolated. The flag is read back and the
VM start fails if it did not take.

The floor is Linux ≥ 4.18 and iproute2 ≥ 4.19 — the kernel side landed in
4.18, but the isolated keyword is absent from bridge/link.c at the v4.18.0
tag and first appears in v4.19.0.

Tests

A Firecracker-gated phase in test_multi_instance asserts, with two live
instances: A cannot ping B directly (L2), A cannot reach B with /32 routes via
the gateway (L3), and A still reaches the host gateway.

The routed probe installs the /32 on both guests deliberately. With only
the initiator's route, B answers over its connected /24 — tap_b to tap_a, two
isolated ports — so the reply dies at L2 and the ping fails whether or not the
FORWARD rule exists. Routing the return leg makes the assertion depend on the
rule alone. The gateway ping runs first as a positive control, since every other
assertion reads a ping failure as success.

port_is_isolated is a pure predicate with unit tests over real
bridge -d link show output, including the kernel-too-old case where the
attribute is absent rather than off. Only the two shell-out wrappers are added
to .cargo/mutants.toml.

Residuals (documented in docs/trust-model.md, not closed here)

  • Isolation is pairwise — the kernel drops a frame only when both ports are
    isolated, so a VM still running from before the upgrade leaves the whole
    bridge unisolated until restarted, not just itself. Restart running VMs
    after upgrading.
  • Impersonation — neither control governs ARP or the guest's own addressing.
    coop reaches guests by IP with host-key checking deliberately disabled, so a
    connection meant for a peer could land on an impostor with its SendEnv
    payload. Pinning MAC/IP (ebtables, static ip neigh) is not implemented.
  • IPv6 and firewall reloads — only iptables is touched, and any
    iptables-restore discards the rule until the next coop up.
  • A pre-existing br0ensure_bridge adopts a bridge it did not create
    without inspecting its members or installing its other rules; on such a host
    the DROP applies to that bridge's traffic too. Making ensure_bridge assert
    all four of its rules idempotently is a follow-up, kept out of this PR to
    avoid mixing a networking refactor into a security fix.

macOS is unaffected: the generated Lima templates declare no networks: stanza,
so each guest gets its own per-instance usernet stack.

Verification

Run and passing: cargo fmt -- --check, cargo clippy --all-targets --all-features -- -D warnings, the unit suite (1112 passing), bash -n and
shellcheck on the integration script.

The integration suite was not run. mod network is dead code on macOS, so
this code path cannot execute on the machine it was written on, and no CI
workflow runs tests/integration.sh. The new phase is additionally
--full-gated and Firecracker-only. It needs
./tests/run-integration.sh --remote user@host --full on a Linux/KVM host
before the assertions here can be trusted.

🤖 Generated with Claude Code

Auditor contributors and others added 2 commits September 4, 2026 13:31
Marking each TAP isolated blocks the L2 path between guests but not the
L3 one: a frame addressed to the bridge is local delivery, so the flag
never applies, and ip_forward sends it back out br0 from the bridge
device, which has no isolated source port either. Nothing in the existing
ruleset matches br0 -> br0, so a root guest could reach a peer with
`ip route add <peer>/32 via <bridge ip>` on any host whose FORWARD policy
is ACCEPT — the kernel default.

Add the matching L3 rule, removed in teardown_bridge alongside the other
three. Assert it on every setup_tap rather than in ensure_bridge, which
returns early on a pre-existing bridge and would leave it silently
absent.

Read the isolated flag back after setting it. IFLA_BRPORT_ISOLATED is
attribute 33; a kernel below 4.18 caps the bridge-port policy at 32 and
silently drops out-of-range attributes, so the set exits 0 having done
nothing. Fail the VM start instead, naming the floor — which is iproute2
>= 4.19, not 4.18: the kernel side landed in 4.18 but the `isolated`
keyword only appears in iproute2 from 4.19.0.

Cover both paths in the multi-instance integration phase. The routed
probe installs the /32 on both guests: with only the initiator's route,
the peer answers over its connected /24 — two isolated ports — so the
reply dies at L2 and the ping fails whether or not the rule exists. It
also runs a positive control first, since every assertion reads a ping
failure as success, and skips when the host FORWARD policy is not ACCEPT,
where the result would not be attributable to coop's rule.

Record the invariant and its residuals in trust-model.md: isolation is
pairwise, so one VM still running from before the upgrade leaves the whole
bridge unisolated until restarted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@DarkaMaul
DarkaMaul marked this pull request as ready for review September 4, 2026 12:29

@hbrodin hbrodin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes for the firewall-precedence bug and the integration test's inability to distinguish the L2 control. Three inline findings total, including one minor correction to the mutation-coverage rationale. The basic two-control design is sound in a disposable Linux network-namespace reproduction.

Validation: real veth/bridge namespace probes confirmed direct isolation, preserved gateway access, the routed bypass without DROP, and the bypass when ACCEPT precedes an existing DROP even after rerunning the startup check. The extracted readback predicate test passed; constant-true and constant-false mutants failed. Shell syntax passed. The bridge-netfilter test blind spot was checked against kernel documentation and an extracted boundary model, not a live br_netfilter run (the module was absent).

Coverage: correctness, design, conventions, security, API usage, tests, docs, and comments; none skipped. No broader structural or diff-noise concern. Current CI is green, but neither Firecracker nor Lima VM integration was run, and CI has no VM integration jobs. The new phase requires --full, skips Lima, and skips its routed assertion when FORWARD policy is not ACCEPT. Run the required VM integration gates after fixing the findings.

This is partial progress toward #5, not completion of guest isolation or #2's egress restrictions. The documented impersonation, IPv6, legacy-port, firewall-reload, and bridge-ownership residuals remain follow-ups. The added FORWARD rule also requires the repository's merge-time human confirmation.

Comment thread src/network.rs
.sudo()
.capture()
.is_ok();
if !present {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reassert rule precedence, not just existence. iptables -C succeeds if the matching DROP exists anywhere in FORWARD. If a firewall configuration puts an ACCEPT ahead of it, this branch skips insertion and startup succeeds while routed guest-to-guest traffic remains possible.

I reproduced this in a disposable network namespace with two isolated bridge ports and reciprocal /32 routes through the gateway: DROP blocked the ping; inserting ACCEPT ahead of it and rerunning this exact check/conditional-insert logic left the ping working. Unlike the documented case where a reload removes the rule, another start does not repair this state. Please verify or safely restore the rule's required precedence and cover the shadowed-rule case.

Comment thread tests/integration.sh
pass "guest A still reaches the host gateway"

# L2: direct on-link path, blocked by the isolated bridge port.
if guest_exec ping -c1 -W2 "$ip_b" >/dev/null 2>&1; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Make this assertion discriminate the bridge-port control. When br_netfilter filters bridged IPv4 traffic, the new FORWARD br0-to-br0 DROP also blocks this direct ping. Removing isolate_tap_port therefore leaves the gateway positive control and both negative ping assertions green, so the test can miss removal of the L2 protection.

The kernel documents the bridge-to-IPv4-netfilter behavior here: https://docs.kernel.org/networking/bridge.html#netfilter . An extracted boundary model confirmed that the assertions accept this case; this was not a live br_netfilter reproduction because the module is absent in the review environment.

Please assert the actual isolation flags on both TAPs and exercise L2 behavior in an isolated environment where the L3 rule cannot mask it. Deliberately removing the L2 control should fail its regression check.

Comment thread .cargo/mutants.toml
# the flag stuck or the rule landed; tests/integration.sh asserts the behavior
# with two running instances. The decision logic they were carved around stays
# IN scope and is unit-tested — port_is_isolated (the readback predicate),
# guest_isolation_spec (subnet scoping + rule tag), and guest_subnet_cidr (the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please update this coverage rationale to match the implementation. guest_isolation_spec and guest_subnet_cidr do not exist in this revision; their names occur only in this comment. The rule is a six-argument GUEST_ISOLATION_SPEC constant without subnet scoping or a rule tag, and port_is_isolated is the only new pure helper. The exclusions themselves are appropriate, but the comment currently promises tests and protections that are absent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants