fix: boxes could reach each other — isolate them at the bridge #37

Merged
dan-claude-bot merged 1 commit from fix/sibling-isolation into main 2026-07-14 01:30:29 +00:00
dan-claude-bot commented 2026-07-14 01:29:35 +00:00 (Migrated from github.com)

Closes the finding from the #15 audit: boxes were not isolated from each other. A live probe caught box A's SYN arriving at box B, with B answering by RST.

FAIL  BOX A REACHES BOX B (10.87.0.183) — sibling isolation does NOT hold [tcp: refused]

Why the ACL never caught it

The ACL is not wrong — it never saw the traffic. Two boxes on one bridge share an L2 segment: their frames are switched between bridge ports and never traverse the netfilter path an L3 rule lives on. So the egress drop on 10.0.0.0/8 (which contains claudenet) and the default ingress drop both looked airtight, and neither ever fired. That is exactly why the original "belt and braces" reading was plausible and wrong.

The fix: an nftables bridge-family rule

nft add table bridge claudebox
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop

The bridge family's forward hook fires exactly when a frame passes from one bridge port to another — which on claudenet means box→box and nothing else. Frames addressed to the gateway are delivered locally (INPUT), and so is anything being routed out to the internet. So dropping every forwarded frame on this bridge isolates the boxes and costs them nothing. DHCP and ARP still work: they are broadcast, and the local delivery to dnsmasq happens on INPUT, not FORWARD.

It lives in claudebox-firewall.sh, so the boot unit re-applies it — the same discipline as the rest of the runtime rules.

Also in this PR

  • dns.mode=none — a box could getent hosts <sibling> and get a name and address out of the gateway's dnsmasq. Blocked connections with open reconnaissance is not isolation. (Verified: public egress is unaffected.)
  • security.ipv4_filtering deliberately NOT adopted — it breaks the box (dockerd comes up but cannot pull or run a container). Tested on a healthy baseline, vetoed, and the reason is recorded so it isn't re-proposed.
  • The design doc stops claiming what was just disproved, and states the mechanism for each edge of the boundary, including the one that was missing.
  • The drill asserts the contract in phase C against the real stack. Phase D's rehearsal is retired — its questions are answered, and the answers are written down where they won't be re-litigated.

The rule this leaves behind

Isolation claims are tested, never reasoned about. The hole existed precisely because a plausible code reading said it could not.

Verify: bash drill/drill.sh --yes → A3 must flip from refused (the packet arrived) to dropped.

🤖 Generated with Claude Code

Closes the finding from the #15 audit: **boxes were not isolated from each other.** A live probe caught box A's SYN arriving at box B, with B answering by RST. ``` FAIL BOX A REACHES BOX B (10.87.0.183) — sibling isolation does NOT hold [tcp: refused] ``` ## Why the ACL never caught it The ACL is not wrong — it **never saw the traffic**. Two boxes on one bridge share an **L2 segment**: their frames are *switched* between bridge ports and never traverse the netfilter path an L3 rule lives on. So the egress drop on `10.0.0.0/8` (which contains `claudenet`) and the default **ingress drop** both looked airtight, and neither ever fired. That is exactly why the original "belt and braces" reading was plausible and wrong. ## The fix: an nftables *bridge-family* rule ```sh nft add table bridge claudebox nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }" nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop ``` The bridge family's forward hook fires **exactly** when a frame passes from one bridge port to another — which on `claudenet` means **box→box and nothing else**. Frames addressed to the gateway are delivered locally (INPUT), and so is anything being routed out to the internet. So dropping every forwarded frame on this bridge isolates the boxes and costs them nothing. DHCP and ARP still work: they are broadcast, and the local delivery to dnsmasq happens on INPUT, not FORWARD. It lives in `claudebox-firewall.sh`, so the boot unit re-applies it — the same discipline as the rest of the runtime rules. ## Also in this PR - **`dns.mode=none`** — a box could `getent hosts <sibling>` and get a name and address out of the gateway's dnsmasq. Blocked connections with open reconnaissance is not isolation. (Verified: public egress is unaffected.) - **`security.ipv4_filtering` deliberately NOT adopted** — it breaks the box (dockerd comes up but cannot pull or run a container). Tested on a healthy baseline, vetoed, and the reason is recorded so it isn't re-proposed. - **The design doc stops claiming what was just disproved**, and states the mechanism for each edge of the boundary, including the one that was missing. - **The drill asserts the contract** in phase C against the real stack. Phase D's rehearsal is retired — its questions are answered, and the answers are written down where they won't be re-litigated. ## The rule this leaves behind **Isolation claims are tested, never reasoned about.** The hole existed precisely because a plausible code reading said it could not. Verify: `bash drill/drill.sh --yes` → A3 must flip from `refused` (the packet arrived) to `dropped`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#37
No description provided.