Bound the LAN over IPv6, and over the range a tailnet uses #18

Merged
branderson merged 2 commits from ipv6-boundary into main 2026-08-25 16:55:18 -07:00
Collaborator

Summary generated by LLM agent

What this fixes

The node firewall's outbound DROPs named IPv4 ranges only, so the boundary proxmox/firewall.fw.template calls "THIS IS THE BOUNDARY" did not apply to IPv6 at all.

This is not the kind of gap where a control turns out weaker than advertised. It is the kind where it does not apply, on a path that is open by default on every network — including networks with no IPv6 anywhere in them. Every Linux host brings up an fe80:: address on every interface whether or not anything routes v6, and a daemon bound to :: answers on it. So the machine beside a runner was reachable across the segment with all four DROPs in force and ip -4 route showing nothing unusual.

This came out of the same review that produced dejima-devbox-hardener#16. The two hypervisor templates share ancestry and shared the gap.

dejima-ci-firewall already blocked IPv6, and it is not this

That script rejects v6 on the job network, with the same argument in its own words: an address family with no allowlist is not a smaller hole than the one the v4 rules close. It is right, and it covers a different position.

Those rules are bound to the job bridge with -i "$b". They are not in front of the VM's own traffic, and on the privileged tier they are not in front of the jobs either, because those run in the host's network namespace where the bridge rules do not apply at all. The node rule is the one that covers root on the runner VM — which on the privileged tier is not the worst case but the ordinary one, reached without any exploit.

So the in-guest rule and the node rule cover different rows of the blast-radius table, and neither substitutes for the other.

The rules

OUT DROP -dest ::/0, as rule 10 of the security group.

Dropped whole rather than mirrored from the v4 list, because IPv6 has no RFC 1918. A v6 network is numbered out of a globally routable prefix delegated by an ISP, so there is no equivalent list to write down: fc00::/7 catches unique-local addresses most networks do not use and misses the prefix a neighbour is actually on. A rule set that looks symmetrical and covers nothing is worse than a blanket drop, because it reads as handled.

On the node rather than in the guest. net.ipv6.conf.all.disable_ipv6=1 is a setting on a machine this page tells you to treat as already lost, and on the privileged tier root is granted rather than assumed.

Also adds OUT DROP -dest 100.64.0.0/10 as rule 9. A tailnet is numbered out of carrier-grade NAT space, which is none of the three RFC 1918 ranges, so a runner on one reached every machine on the overlay.

Both rules get the same treatment as rules 5–8 in the "one thing to weigh before applying them to a confined host" note, since they can drop destinations a job legitimately declared. Rule 10 has one extra wrinkle covered there: it drops IPv6 egress to the internet as well, which changes nothing for a confined job — dejima-ci-firewall already rejects it — but could matter to a privileged job fetching from a v6-only host. The note says the fix there is a pinhole, not dropping the rule, because dropping it reopens the LAN too.

Verification, and an asymmetry worth stating

Rule 10 gets a by-hand check in proxmox/README.md: ask ff02::1 — the all-nodes link-local multicast address — who is on the link, and read the replies that are not this VM's own.

It stays by-hand deliberately. Everything this repository verifies, it verifies from inside a job in test/ci-runner.bats, and a job is the wrong vantage point for this question: rule 10 is about the VM's own reach, and a confined job's traffic is stopped by dejima-ci-firewall long before it reaches the tap. The sister project checks the equivalent automatically because it has an in-VM audit to hang it on. This repository has nothing that runs inside the VM.

That is called out in the runbook and again in a comment on the existing ci-runner.bats IPv6 test, so the two are not mistaken for each other later. Building an in-VM audit here would be a larger change than this one and is not attempted.

ff02::1 is not a target worth writing down: it is the same address on every network there has ever been, so naming it describes no topology.

The ip6tables -S step is also new, and matters more than it looks: a rule with an IPv6 destination is compiled into ip6tables and is absent from iptables -S, so checking only the v4 table shows rule 10 missing whether it is there or not.

Docs

  • docs/threat-model.md gains a section on the two address families and which layer's rule covers which row.
  • A Not bounded section: the page's negative space collected in one list — a job running hostile code, root on a privileged runner, exfiltration, the forge and resolver pinholes, the registration credential, what a merge lets through, detection and forensics, inbound exposure, the hypervisor. The page was organised entirely by control, which left the reader to assemble that themselves.
  • Two new entries under Rejected, with reasons: mirroring the DROPs into IPv6, and disabling IPv6 in the guest. Each names what would reopen it.
  • This page has a date on it: a quarterly review cadence listing conditions to check rather than judgements to re-make — including whether the forge has grown an approval gate, and whether the rollback is still running, which is the one control here that fails silently and completely.

Tested

bats test/ — 100 passing, up from 97.

New: the six dropped ranges present; ::/0 present specifically, with the layering argument in the test's own comment; and that policy_in: DROP needed no IPv6 counterpart, since a policy is not a list and cannot leave a family out — which is the clearest illustration on the page of what deny-by-default actually buys.

test/addresses.bats gains 100.64.0.0/10 on its allowlist with a comment saying what it is for, and a note that IPv6 literals are not checked because the only one committed is ::/0, which names no network.

Not tested

Neither the ::/0 rule nor the ff02::1 probe has been run against a real Proxmox node — this repository's tests assert on the template and on a live runner, and no runner was available. Two things to confirm on first application: that the console accepts ::/0 as a destination (the runbook gives the four-rule fallback if it does not), and that Proxmox does not separately permit NDP around the rule.

**Summary generated by LLM agent** ## What this fixes The node firewall's outbound DROPs named IPv4 ranges only, so the boundary `proxmox/firewall.fw.template` calls "THIS IS THE BOUNDARY" did not apply to IPv6 at all. This is not the kind of gap where a control turns out weaker than advertised. It is the kind where it does not apply, on a path that is open by default on every network — including networks with no IPv6 anywhere in them. Every Linux host brings up an `fe80::` address on every interface whether or not anything routes v6, and a daemon bound to `::` answers on it. So the machine beside a runner was reachable across the segment with all four DROPs in force and `ip -4 route` showing nothing unusual. This came out of the same review that produced [dejima-devbox-hardener#16](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/pulls/16). The two hypervisor templates share ancestry and shared the gap. ## `dejima-ci-firewall` already blocked IPv6, and it is not this That script rejects v6 on the job network, with the same argument in its own words: an address family with no allowlist is not a smaller hole than the one the v4 rules close. It is right, and it covers a different position. Those rules are bound to the job bridge with `-i "$b"`. They are not in front of the VM's own traffic, and on the **privileged** tier they are not in front of the jobs either, because those run in the host's network namespace where the bridge rules do not apply at all. The node rule is the one that covers root on the runner VM — which on the privileged tier is not the worst case but the ordinary one, reached without any exploit. So the in-guest rule and the node rule cover different rows of the blast-radius table, and neither substitutes for the other. ## The rules `OUT DROP -dest ::/0`, as rule 10 of the security group. Dropped whole rather than mirrored from the v4 list, because **IPv6 has no RFC 1918**. A v6 network is numbered out of a globally routable prefix delegated by an ISP, so there is no equivalent list to write down: `fc00::/7` catches unique-local addresses most networks do not use and misses the prefix a neighbour is actually on. A rule set that looks symmetrical and covers nothing is worse than a blanket drop, because it reads as handled. On the node rather than in the guest. `net.ipv6.conf.all.disable_ipv6=1` is a setting on a machine this page tells you to treat as already lost, and on the privileged tier root is granted rather than assumed. Also adds `OUT DROP -dest 100.64.0.0/10` as rule 9. A tailnet is numbered out of carrier-grade NAT space, which is none of the three RFC 1918 ranges, so a runner on one reached every machine on the overlay. Both rules get the same treatment as rules 5–8 in the "one thing to weigh before applying them to a confined host" note, since they can drop destinations a job legitimately declared. Rule 10 has one extra wrinkle covered there: it drops IPv6 egress to the internet as well, which changes nothing for a confined job — `dejima-ci-firewall` already rejects it — but could matter to a privileged job fetching from a v6-only host. The note says the fix there is a pinhole, not dropping the rule, because dropping it reopens the LAN too. ## Verification, and an asymmetry worth stating Rule 10 gets a **by-hand** check in `proxmox/README.md`: ask `ff02::1` — the all-nodes link-local multicast address — who is on the link, and read the replies that are not this VM's own. It stays by-hand deliberately. Everything this repository verifies, it verifies from inside a job in `test/ci-runner.bats`, and a job is the wrong vantage point for this question: rule 10 is about the VM's own reach, and a confined job's traffic is stopped by `dejima-ci-firewall` long before it reaches the tap. The sister project checks the equivalent automatically because it has an in-VM audit to hang it on. This repository has nothing that runs inside the VM. That is called out in the runbook and again in a comment on the existing `ci-runner.bats` IPv6 test, so the two are not mistaken for each other later. Building an in-VM audit here would be a larger change than this one and is not attempted. `ff02::1` is not a target worth writing down: it is the same address on every network there has ever been, so naming it describes no topology. The `ip6tables -S` step is also new, and matters more than it looks: a rule with an IPv6 destination is compiled into `ip6tables` and is absent from `iptables -S`, so checking only the v4 table shows rule 10 missing whether it is there or not. ## Docs - **`docs/threat-model.md`** gains a section on the two address families and which layer's rule covers which row. - A **Not bounded** section: the page's negative space collected in one list — a job running hostile code, root on a privileged runner, exfiltration, the forge and resolver pinholes, the registration credential, what a merge lets through, detection and forensics, inbound exposure, the hypervisor. The page was organised entirely by control, which left the reader to assemble that themselves. - Two new entries under **Rejected, with reasons**: mirroring the DROPs into IPv6, and disabling IPv6 in the guest. Each names what would reopen it. - **This page has a date on it**: a quarterly review cadence listing conditions to check rather than judgements to re-make — including whether the forge has grown an approval gate, and whether the rollback is still running, which is the one control here that fails silently and completely. ## Tested `bats test/` — 100 passing, up from 97. New: the six dropped ranges present; `::/0` present specifically, with the layering argument in the test's own comment; and that `policy_in: DROP` needed no IPv6 counterpart, since a policy is not a list and cannot leave a family out — which is the clearest illustration on the page of what deny-by-default actually buys. `test/addresses.bats` gains `100.64.0.0/10` on its allowlist with a comment saying what it is for, and a note that IPv6 literals are not checked because the only one committed is `::/0`, which names no network. ## Not tested Neither the `::/0` rule nor the `ff02::1` probe has been run against a real Proxmox node — this repository's tests assert on the template and on a live runner, and no runner was available. Two things to confirm on first application: that the console accepts `::/0` as a destination (the runbook gives the four-rule fallback if it does not), and that Proxmox does not separately permit NDP around the rule.
dejima-agent added 1 commit 2026-08-25 14:51:23 -07:00
Bound the LAN over IPv6, and over the range a tailnet uses
tests / runner-env (confined) (pull_request_target) Successful in 40s
tests / tests (pull_request_target) Successful in 1m11s
tests / compose-schema (pull_request_target) Successful in 46s
tests / runner-env (privileged) (pull_request_target) Successful in 35s
ec521afd84
The node firewall's DROPs named IPv4 ranges only, so the boundary this
runbook calls "the boundary" did not apply to IPv6 at all. That is open by
default on every network including ones with no IPv6 in them: every Linux
host brings up an fe80:: address on every interface whether or not anything
routes v6, and a daemon bound to :: answers on it. Neighbours were reachable
across the segment with all four DROPs in force and `ip -4 route` showing
nothing unusual.

bin/dejima-ci-firewall already blocked v6, and it is a different position. It
covers a confined job reaching off its bridge. It is not in front of the VM's
own traffic, and on the privileged tier it is not in front of the jobs
either, since those run in the host's network namespace. The node rule is the
one covering root on the VM, which on that tier is the ordinary case rather
than the worst one.

Dropped whole rather than enumerated, because IPv6 has no RFC 1918. A v6
network is numbered out of a globally routable delegated prefix, so no
equivalent range list can be written here - fc00::/7 catches ULAs most
networks do not use and misses the prefix a neighbour is actually on. On the
node rather than in the guest, for the reason the whole file is on the node.

Also drops 100.64.0.0/10, where a tailnet is numbered.

Rule 10 gets a by-hand check in proxmox/README.md rather than an automated
one: it asks ff02::1 who is on the link and counts the replies. Nothing in
this repository runs inside the VM - test/ci-runner.bats runs inside a job,
which is the wrong vantage point, since a confined job's traffic is stopped
by dejima-ci-firewall before it reaches the tap. That asymmetry with the
sister project is noted where the test is.

Docs: the threat model gains a section on the two address families and where
each one's rule sits, a "Not bounded" list collecting the negative space in
one place, two new entries under "Rejected, with reasons", and a quarterly
review cadence with conditions to check rather than re-argue.

Tested with `bats test/` (100 passing).
dejima-agent added 1 commit 2026-08-25 16:38:46 -07:00
Describe the boundary as it is, not as it changed
tests / runner-env (confined) (pull_request_target) Successful in 41s
tests / tests (pull_request_target) Successful in 1m12s
tests / compose-schema (pull_request_target) Successful in 47s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
afbe827e0f
The threat model and the runbook are descriptions of the current tool, not
a record of what moved. The IPv6 sections read as a changelog: what the rule
list used to name, and what was reachable before. That belongs in the pull
request, which has it.

Also drops 'already' where it described this repository's own history rather
than the relationship between two layers, which is what the sentence was
about.

Same rules, same reasoning, stated in the present tense.
branderson approved these changes 2026-08-25 16:55:10 -07:00
branderson merged commit 7a0a3becbf into main 2026-08-25 16:55:18 -07:00
Sign in to join this conversation.