Bound the LAN over IPv6, and over the range a tailnet uses
tests / runner-env (pull_request_target) Successful in 39s
tests / tests (pull_request_target) Successful in 1m2s

The node firewall's DROPs named IPv4 ranges only, so the control this
project exists to provide 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, `ip -4 route` showed
nothing unusual, and `dejima-devbox-audit reach` reported PASS.

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 same reason as every other rule here:
a sysctl is a setting root on the guest puts back.

Also drops 100.64.0.0/10. A tailnet is numbered there and is none of the
three RFC 1918 ranges, so a devbox on one reached every machine on the
overlay.

The audit gets a matching check: it asks ff02::1 who is on the link and
counts the replies that are not its own. No positive control, because both
reasons for v6 silence are the outcome wanted - unlike the v4 case, where a
working firewall and a broken network look identical. It does report
INDETERMINATE when the probe cannot run at all, which is why bootstrap now
installs iputils-ping; without it the check would have passed vacuously,
which is the one thing this program is built not to do.

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

Tested with `bats test/` (114 passing) and by exercising the probe against
recorded ping output, including its own reply and DUP! lines.
This commit is contained in:
2026-08-25 14:40:23 -07:00
parent 1f75dd422a
commit 7cba3932c2
11 changed files with 639 additions and 30 deletions
+14
View File
@@ -71,6 +71,11 @@ layer grants is [docs/threat-model.md](docs/threat-model.md), including the
[blast radius by layer](docs/threat-model.md#blast-radius-by-layer) and the
options that were [rejected, with reasons](docs/threat-model.md#rejected-with-reasons).
The shortest way to find out whether this is the tool you want is
[Not bounded](docs/threat-model.md#not-bounded), which is the same page's
negative space collected in one list rather than left to be inferred from what
each section happens to discuss.
Container escape is deliberately deprioritised. An escape gains root, which is
more than the container has, and root on this VM is already a row in that table,
bounded by the same hypervisor firewall however it was reached. So
@@ -131,6 +136,15 @@ is not, and inside the ranges the firewall drops.
A check that reports success when it does not know is worse than no check,
because it gets believed.
**The IPv6 half has the same three states for different reasons.** It asks the
segment who is there over IPv6 and counts the answers, and there a silence is
unambiguous: the node dropped the family, or the guest never had it, and both
are the outcome wanted. So silence is a plain `PASS` with no positive control.
`INDETERMINATE` is reserved for the probe not running at all, which is still not
a pass. It is a separate check because the node's other rules name IPv4 ranges
and match none of that traffic - see
[the LAN boundary is two boundaries](docs/threat-model.md#the-lan-boundary-is-two-boundaries).
**A rule that REJECTs rather than DROPs reports `FAIL` even though it is
working.** `bash`'s `/dev/tcp` cannot tell a rejection from a live host
refusing. That is the safe direction to be wrong in, and it is why the template
+157 -2
View File
@@ -19,10 +19,17 @@
# it must be unreachable as a destination, while traffic *through* it to
# the internet is untouched.
#
# That covers IPv4. The second target is ff02::1, the all-nodes link-local
# multicast address, because the node's DROPs name IPv4 ranges and a guest with
# IPv6 reach onto the LAN satisfies every check above without any of them
# noticing. See "the other address family" below.
#
# Nothing here is persisted. A file listing what this VM must not reach would
# be a map of valuable targets kept on the machine most likely to be owned, and
# would describe topology not otherwise obtainable from here without loud
# scanning. There is no such file and there should never be one.
# scanning. There is no such file and there should never be one. Neither target
# is such a list: the gateway is already in the routing table, and ff02::1 is
# the same address on every network there has ever been.
set -euo pipefail
@@ -95,6 +102,120 @@ gateway_verdict() {
fi
}
# --- the other address family -----------------------------------------------
#
# Everything above is IPv4 by construction: `ip -4 route`, an IPv4 gateway, and
# IPv4 ranges dropped on the node. A guest that can reach the LAN over IPv6
# passes every one of those checks with nothing to show for it, so the question
# gets asked separately here.
#
# It is a different shape of check and deliberately so. There is no positive
# control, because unlike the v4 case both ways of passing are outcomes worth
# having - v6 dropped at the node, or v6 never configured at all - and no
# service a devbox needs lives on this family for the silence to be hiding.
# What is ruled out is the third case: working v6 reach with rules written only
# for v4. dejima-ci-hardener argues the same way in its own runner tests.
# Which interface faces the LAN.
#
# Read from the v4 default route rather than the v6 one, because where this
# check is working correctly there is no v6 default route to read - and that is
# precisely the case it has to keep working in.
lan_iface() {
local dev
dev=$(ip -4 route show default 2>/dev/null \
| awk '{for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit }}')
[ -n "$dev" ] || return 1
printf '%s\n' "$dev"
}
# An IPv6 address on this host that is not link-local, or nothing.
#
# Link-local is excluded because it is always there. The kernel brings up an
# fe80:: address on every interface whether or not anything routes v6, so its
# presence says nothing and reading it as reach would fail every correctly
# contained VM.
#
# A global or unique-local address is different. It arrived by SLAAC or DHCPv6,
# which means a router advertisement reached this guest and a reply left it, so
# v6 is demonstrably not being dropped at the node. No probe needed: the address
# is the evidence.
ipv6_routable_addr() {
ip -6 -o addr show scope global 2>/dev/null | awk '{print $4; exit}'
}
# Reply addresses in ping's output that are not ours, counted.
#
# Split out from the probe so the decision can be tested without a fake ping on
# PATH, which is this suite's rule. Excluding our own addresses is the whole of
# the decision: we answer our own multicast, and counting that would report
# every contained VM as a failure.
ipv6_replies_from_others() { # ipv6_replies_from_others PING_OUTPUT OWN_ADDRS
printf '%s\n' "$1" \
| sed -n 's/.*bytes from \([0-9a-fA-F:]\{2,\}\).*/\1/p' \
| sort -u \
| grep -vxF "${2:-::}" \
| grep -c . || true
}
# How many other hosts answer on the link over IPv6.
#
# ff02::1 is the all-nodes link-local multicast address and every IPv6 host
# answers it. Every Linux host is an IPv6 host: the fe80:: address above exists
# on networks that route no v6 at all, and a daemon bound to :: answers on it.
# So this finds neighbours that a v4 reading of the network says are not there.
#
# It is not the target list this program refuses to keep. ff02::1 is the same
# address on every network there has ever been, so writing it down describes no
# topology and leaks nothing about this one.
#
# Returns non-zero when the question could not be asked at all, which the caller
# turns into INDETERMINATE. Printing 0 instead would be the same lie as reading a
# timeout as a pass: nobody answered because nobody was asked.
ipv6_neighbour_replies() {
local iface out own
iface=$(lan_iface) || return 1
command -v ping >/dev/null 2>&1 || return 1
out=$(timeout "$PROBE_TIMEOUT" ping -6 -n -c 2 -W 1 -I "$iface" ff02::1 2>/dev/null || true)
own=$(ip -6 -o addr show 2>/dev/null | awk '{sub(/\/.*/, "", $4); print $4}')
ipv6_replies_from_others "$out" "$own"
}
# FAIL | PASS | INDETERMINATE
#
# Three states, but not the three above, and the difference is the point.
#
# There, INDETERMINATE is silence this program cannot interpret: a working
# firewall and a broken network look identical from in here. That ambiguity does
# not exist on this family. Both of the reasons a segment stays silent over IPv6
# - dropped at the node, or never configured in the guest - are the outcome
# wanted, and no service a devbox needs lives here for the silence to be hiding
# one. So a silent segment is a plain PASS and needs no positive control.
#
# What INDETERMINATE means here is that the question was never asked: no `ping`
# installed, or no interface to ask on. That is not containment, it is an unrun
# check, and reporting it as a pass is the failure this whole program is built
# around not committing.
ipv6_verdict() {
local addr replies
addr=$(ipv6_routable_addr)
# Checked first because it needs no probe. An address is evidence on its own,
# so a host with one is FAIL even where nothing could have been asked.
if [ -n "$addr" ]; then
printf 'FAIL\n'
return 0
fi
if ! replies=$(ipv6_neighbour_replies); then
printf 'INDETERMINATE\n'
return 0
fi
if [ "${replies:-0}" -gt 0 ]; then
printf 'FAIL\n'
else
printf 'PASS\n'
fi
}
cfg_get() {
local f="$CONF_DIR/config" v=""
[ -r "$f" ] && v=$(sed -n "s/^$1=//p" "$f" | tail -1)
@@ -200,7 +321,7 @@ positive_controls_ok() {
}
cmd_reach() {
local verdict rc=0
local verdict v6 rc=0
verdict=$(gateway_verdict) || return $?
case $verdict in
PASS)
@@ -224,6 +345,40 @@ cmd_reach() {
rc=1
;;
esac
v6=$(ipv6_verdict)
case $v6 in
PASS)
say "PASS: nothing on the LAN answers this VM over IPv6."
;;
FAIL)
say "FAIL: this VM reaches the LAN over IPv6."
say " The DROPs on the node name IPv4 ranges and match none of this"
say " traffic, so the v4 result above says nothing about it."
say " Add the outbound IPv6 rule - proxmox/README.md section 3, rule"
say " 12 - which drops the family whole because IPv6 has no range"
say " list to enumerate the way RFC 1918 does."
say " Disabling IPv6 in the guest is not the same control: that is a"
say " setting root here can put back, and root here is the case."
# Only overwrite a pass. INDETERMINATE already means "not a pass" and
# is the more specific thing to have said, so it is left standing.
if [ "$verdict" = PASS ]; then
verdict=FAIL
fi
rc=1
;;
INDETERMINATE)
say "INDETERMINATE: the IPv6 question could not be asked."
say " Not a pass. Nothing answered because nothing was sent."
say " Install iputils-ping and run again. dejima-devbox-bootstrap"
say " installs it; a host set up before it did will not have it."
if [ "$verdict" = PASS ]; then
verdict=INDETERMINATE
fi
rc=1
;;
esac
printf '%s\n' "$verdict"
return $rc
}
+7 -1
View File
@@ -109,9 +109,15 @@ apt-get update -qq
# python3 and jq are dejima's own prerequisites: the host-side credential proxy
# is python3, and its installer reads settings.json with jq. git is here for
# the clone below.
#
# iputils-ping is the audit's, and it is named rather than assumed. A standard
# Debian install has it and a minimal or cloud image often does not, and its
# absence has no symptom: without it the audit cannot ask the segment who is
# reachable over IPv6, which is a check it must then report as INDETERMINATE
# rather than pass. Cheaper to install than to diagnose.
apt-get install -y -qq --no-install-recommends \
docker.io docker-cli \
python3 jq git ca-certificates curl
python3 jq git ca-certificates curl iputils-ping
if ! command -v docker >/dev/null 2>&1; then
{
+11
View File
@@ -133,6 +133,17 @@ read `none found` on a box full of credentials.
tell them apart on its own. Reporting it as success would be the one failure
that matters: a check believed when it does not know.
**`reach` runs a second check, on IPv6, and reports it on its own line.** The
node's DROPs name IPv4 ranges and match none of that traffic, so a VM that
reaches the whole LAN over IPv6 passes every check above with nothing to show
for it. This one asks the segment who is there and counts the answers. It uses
`ping`, from `iputils-ping`, which §5's bootstrap installs — without it the
check reports `INDETERMINATE`, which is again not a pass.
If it says `FAIL`, the fix is rule 12 on the node, not a sysctl in here. See
[the threat model](threat-model.md#the-lan-boundary-is-two-boundaries) for why
that distinction is not pedantry.
## 7. Keep the credentials off this box
Push with a **deploy token scoped to the repositories this box works on**, not
+44 -1
View File
@@ -40,6 +40,49 @@ routing failure can produce that.
refusing and produces `FAIL`. That is the safe direction to be wrong in, and it
is why the template specifies DROP.
## The IPv6 check
`dejima-devbox-audit reach` runs a second check on the other address family and
reports on it separately. Anything but `PASS` there fails the whole run, even
when the gateway probe passed.
| Verdict | Means | Exit |
| --- | --- | --- |
| `FAIL` | A global or unique-local IPv6 address is configured here, **or** something that was not this VM answered `ff02::1` | 1 |
| `PASS` | Neither, and the probe ran | 0 |
| `INDETERMINATE` | The probe could not run: no `ping`, or no interface to ask on | 1 |
**The three states are not the three above.** There, `INDETERMINATE` is silence
this program cannot interpret, because a working firewall and a broken network
look identical from inside the VM. That ambiguity does not exist here. Both
reasons a segment stays silent over IPv6 - dropped at the node, or never
configured in the guest - are the outcome wanted, and no service a devbox needs
lives on this family for the silence to be hiding one. So a silent segment is a
plain `PASS` and there is no positive control, because there is nothing to
disambiguate.
`INDETERMINATE` here means the question was never asked. `ping` comes from
`iputils-ping`, which `dejima-devbox-bootstrap` installs and a host set up
before it did will not have. Nobody answering because nobody was asked is not
containment, and the one thing this program will not do is call it a pass.
**Why an address is enough on its own.** A global or unique-local address got
here by SLAAC or DHCPv6, so an advertisement reached this guest and a reply left
it. That is the node not dropping v6, evidenced rather than inferred. Link-local
is excluded because the kernel brings one up regardless and its presence says
nothing.
**Why `ff02::1`.** It is the all-nodes link-local multicast address and every
IPv6 host answers it - and every Linux host is an IPv6 host, on a network that
routes no v6 at all. So it finds neighbours a v4 reading says are not there. Our
own replies are excluded; counting them would fail every contained VM. Like the
gateway, it is not a target worth writing down: it is the same address on every
network there has ever been.
**Why this is not the gateway probe with a `-6`.** Where the rule is working
there is no IPv6 default route to probe, so the check has to work without one.
It takes its interface from the v4 default route instead.
## The positive controls
Either one is sufficient:
@@ -53,7 +96,7 @@ Requiring both would turn an unrelated outage into a report that gets ignored.
| Mode | Boundary cost | Maintenance |
| --- | --- | --- |
| Static in the guest | None. Four DROPs plus the named exceptions, and no DHCP rules | The address is asserted in two places |
| Static in the guest | None. Six DROPs plus the named exceptions, and no DHCP rules | The address is asserted in two places |
| Reserved at the router | Needs both halves of the DHCP pair | One source of truth |
| Plain DHCP | Same DHCP pair | None, but the address moves |
+119 -1
View File
@@ -159,6 +159,41 @@ changes.
What dejima cannot bound is the devbox VM's own network access, because dejima
runs inside that VM. That gap is this project's entire subject.
## The LAN boundary is two boundaries
The control aimed at lateral movement is a list of ranges dropped on the
hypervisor, and a list bounds only what it names. This one named IPv4 ranges
only, until it was found not to.
That is not the kind of gap where a control turns out weaker than advertised.
It is the kind where the control does not apply at all, 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 this one was reachable across the segment with all four DROPs in force,
`ip -4 route` showed nothing unusual, and `dejima-devbox-audit reach` said
`PASS`.
**IPv6 has no RFC 1918.** The v4 list works because three ranges cover every
private network there is. A v6 network is numbered out of a globally routable
prefix delegated by an ISP, so no equivalent list can be written down here:
`fc00::/7` catches unique-local addresses most networks do not use and misses
the prefix a neighbour is actually on. The set cannot be enumerated, so the
family is dropped whole, on the node, as rule 12 of the security group.
This costs nothing that was being claimed. Internet egress stays unbounded and
stays over IPv4, for the reasons argued in `proxmox/firewall.fw.template`. What
rule 12 removes is a second route to the internet and a first route to the LAN.
The audit verifies it the way it verifies the rest, by behaviour: it asks
`ff02::1` who is on the link and counts the replies that are not its own. A v4
timeout is ambiguous, because a working firewall and a broken network look alike
from in here. A v6 silence is not, because both of its causes - dropped at the
node, or never configured at all - are the outcome wanted, and no service a
devbox needs lives on that family for the silence to be hiding one. So that half
needs no positive control. What it does still report separately is a probe that
could not run at all, which is not containment and is not allowed to read as it.
## Blast radius by layer
| Layer | Reached by | Radius |
@@ -166,7 +201,7 @@ runs inside that VM. That gap is this project's entire subject.
| Container | normal operation | the forge credential's scope, mounted paths, and egress to allowlisted domains only |
| Devbox VM, unprivileged | container escape | everything on the VM the account can read, and **unrestricted egress to the public internet** |
| Devbox VM, root | escape plus local escalation, or misconfiguration | the whole VM, the same unrestricted internet egress, and **the model API credential** |
| LAN | any of the above, if unbounded | every host the VM can route to |
| LAN | any of the above, if unbounded | every host the VM can route to, in either address family |
| Hypervisor | out of scope | named for completeness |
The jump from row one to row two is the one to read twice, and it is not only
@@ -181,6 +216,38 @@ spread to, rather than whether it can talk. And it makes every row verifiable
rather than asserted. `dejima-devbox-audit` is the
verification: rows four and five by behaviour, rows two and three by inventory.
## Not bounded
Everything above is organised by control, which leaves the reader to assemble
the negative space themselves. Most will not. So, in one place, what a devbox
running this is *not* protected from. Each line has its argument somewhere above
or below; this list exists so that nobody has to go looking to find out whether
there is one.
- **Exfiltration, and command-and-control.** Egress to the internet is
unbounded from the VM, deliberately. A payload out on the VM fetches what it
likes and reports to whom it likes. dejima's allowlist bounds this inside the
container, and an escape is precisely what leaves it behind.
- **Container escape.** Deprioritised on purpose. Nothing here separates an
escape from root on the VM, and the rejected alternatives are below.
- **Root on the devbox.** Not prevented. Bounded, stated, and checkable, which
is a different claim.
- **The model API credential.** On this box, and it cannot be moved off,
because the proxy that holds it has to read it here. An escape converts "can
use the API while this session lasts" into "holds the token".
- **The two LAN exceptions.** The forge and the resolver are reachable from
this VM on the ports listed, by anything on it. "No LAN access" is not
literally true and never was.
- **Inbound exposure.** The node's inbound list is yours to keep short and
nothing here checks it. It can only be checked from outside the VM, and this
tool does not go looking for things to connect to.
- **Other people's work.** Out of spec. One person's work per devbox is a
condition, not an observation.
- **History.** The audit reports state. There is no detection and no forensics
here, so it will tell you a rule is missing and never that it once was.
- **The hypervisor itself.** Out of scope, and it is the layer everything above
is standing on.
## Rejected, with reasons
Recorded so they are not re-proposed without new information. Each of these is
@@ -255,6 +322,27 @@ The half worth closing is agent memory, and a Unix account boundary is not what
closes it. dejima scopes the `projects` and `file-history` mounts, which is
where that control belongs: in the repository that owns the mount.
**Mirroring the LAN DROPs into IPv6 instead of dropping the family.** Rejected
because the list cannot be written. There is no v6 range that means "private",
so the closest anyone can get is dropping `fc00::/7` and leaving the guest able
to reach every neighbour on the prefix the ISP delegated. A rule set that looks
symmetrical and covers nothing is worse than the blanket drop, because it reads
as handled by anyone reviewing it later.
Revisit if the devbox ever genuinely needs IPv6 egress. The work then is to find
out which prefix your own LAN is on and drop that instead, and to know that the
prefix changes when the delegation does - so the rule acquires a way to go stale
that none of the v4 rules have.
**Disabling IPv6 in the guest, with `net.ipv6.conf.all.disable_ipv6`.** Rejected
for the reason every other control here sits on the node: it is a setting on a
machine this model assumes an attacker holds root on, and root sets it back.
It is worse than merely ineffective. It would also make the audit's own IPv6
check pass, so the guest would be certifying its own containment on the strength
of a setting the guest controls - which is the exact failure the whole
verify-by-behaviour design exists to avoid.
**Scheduled rollback.** Rejected because it destroys uncommitted work, which is
the thing a devbox exists to hold. This is the largest single difference from
the sister project, whose entire containment story is that a runner is rebuilt
@@ -267,6 +355,36 @@ reports state, not history.
controls belong in dejima. Where this project needs something from there, it is
raised there rather than worked around here.
## This page has a date on it
Every argument above is reasoning about a particular idea of what the adversary
can do, and that idea does not keep. The controls are aimed at a payload nobody
aimed at this machine and at an agent that can be talked into things, and
neither of those descriptions has been getting more reassuring.
Hardening designed against the agent of two years ago is not obviously adequate
against the agent of today, and the honest position is that nobody here can tell
you where the line is. What can be done is to re-read the reasoning on a
schedule rather than when something goes wrong.
**Quarterly**, matching dejima's own cadence, and the argument applies harder
here than there: this is the layer that catches what the layer above fails to
hold, so it is the one where a stale assumption goes unnoticed longest.
What to check, rather than re-argue:
- the revisit conditions on each entry in "Rejected, with reasons". They are
written as facts to look up, not judgements to re-make, so the review is
cheap and has an answer;
- whether the sibling directories dejima mounts host-wide - `tasks`, `plans`,
`sessions`, `backups` - have started being loaded into a session's context at
startup, which is the property that would make them worth scoping;
- whether anything has changed about what an escape is worth, since that is the
premise the deprioritisation rests on and not a conclusion of it;
- whether the address families, ports and ranges named on the node still cover
what a neighbour of this VM can be addressed by. IPv6 was missing from that
list for as long as the list existed, and nothing failed in the meantime.
## What this repository does not claim
It does not prevent root on the devbox. Nothing here tries to.
+86 -15
View File
@@ -156,19 +156,19 @@ running.
## 3. The firewall rules
Ten rules, built once as a **security group** and applied to each devbox. Even
with one VM, build the group: hand-entered copies are two chances to get the
ordering wrong and one of them to drift later.
Twelve rules, built once as a **security group** and applied to each devbox.
Even with one VM, build the group: hand-entered copies are two chances to get
the ordering wrong and one of them to drift later.
This is the primary control. Everything else in this repository exists to
verify it or to explain it.
### What each part is for
The outbound half is short and does the work. Four DROPs cover every private
range, and above them sit exceptions for the two hosts this VM genuinely needs:
your forge and your resolver. That is the bound this whole project states, and
it does not change however long the inbound list gets.
The outbound half is short and does the work. Six DROPs cover every address a
neighbour of this VM can hold, and above them sit exceptions for the two hosts
it genuinely needs: your forge and your resolver. That is the bound this whole
project states, and it does not change however long the inbound list gets.
The inbound half is the part that grows, because unlike a CI runner a devbox
offers services you want to reach. Keep it legible rather than short: every
@@ -191,6 +191,8 @@ subnet named here, every port stated, and no rule added anywhere else.
| 8 | out | DROP | — | — | **Dest:** `172.16.0.0/12` | |
| 9 | out | DROP | — | — | **Dest:** `192.168.0.0/16` | The three that matter |
| 10 | out | DROP | — | — | **Dest:** `169.254.0.0/16` | Link-local, incl. metadata services |
| 11 | out | DROP | — | — | **Dest:** `100.64.0.0/10` | Carrier-grade NAT, where a tailnet lives |
| 12 | out | DROP | — | — | **Dest:** `::/0` | IPv6, the whole family |
**Rules 1 and 2 are added once per client subnet.** Phones and laptops sit on
different wireless networks often enough that assuming one is the wrong
@@ -204,6 +206,47 @@ years before it is suddenly very wrong.
it and `git push` hangs against rule 7 rather than failing, which reads as the
forge being down.
**Rule 11 is for a tailnet.** Tailscale numbers its hosts out of
`100.64.0.0/10`, which is none of the three RFC 1918 ranges, so without this
rule a devbox on your tailnet reaches every machine you own anywhere — which is
a wider network than the one rules 7 to 9 were drawn around, not a narrower one.
Add it whether or not you run Tailscale today: it costs nothing on a devbox that
has no overlay, and the day someone installs one is not a day anyone revisits
the firewall.
#### Rule 12, and why IPv6 is dropped whole
Rules 7 to 11 work because the protected set is enumerable. RFC 1918 named three
ranges, your LAN is inside them, and the list is short enough to read.
**IPv6 has no RFC 1918.** A v6 network is numbered out of a globally routable
prefix delegated by your ISP, so there is no equivalent list to write down.
Dropping `fc00::/7` catches unique-local addresses most networks do not use and
misses the prefix your actual neighbour is on. Since the set cannot be
enumerated, the family goes.
This costs a devbox nothing. The internet is still reached over IPv4 by the
default policy, unbounded and on purpose. Rule 12 removes a second path to the
same place, not the only path.
It is worth having even if you are certain your network has no IPv6. 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. Without rule 12, rules 7 to
11 can all be in force and the machine beside this one is still one
`ssh fe80::...%eth0` away. That path does not show up in `ip -4 route`, does not
involve your router, and is invisible to anyone checking the v4 rules are right.
**Do not do this in the guest instead.** `net.ipv6.conf.all.disable_ipv6=1` looks
equivalent and is not: it is a setting on a machine this whole file assumes an
attacker holds root on, and root can set it back. The rule belongs on the node
for the same reason every other rule here does.
If the console rejects `::/0`, the same coverage is four rules — `fe80::/10`,
`fc00::/7`, `ff00::/8`, `2000::/3` — placed together in rule 12's position.
Prefer the single line: four entries are four chances to enter three.
`dejima-devbox-audit reach` checks this one by behaviour, like the rest.
#### Why the front door gets the narrower list
The intuition that SSH is the more dangerous of the two is backwards.
@@ -231,7 +274,7 @@ construction:
#### What this list does not bound
Adding a client subnet widens **who can reach this VM**. It does not widen what
this VM can reach, which is the bound rules 7 to 10 fix and the one this
this VM can reach, which is the bound rules 7 to 12 fix and the one this
project exists to state. The two are different axes and conflating them leads
to refusing a harmless convenience.
@@ -389,15 +432,24 @@ nothing:
# what is actually loaded right now
sudo iptables -S | grep "tap<vmid>i0"
# the same, for the other address family. Rule 12 lands here and nowhere else
sudo ip6tables -S | grep "tap<vmid>i0"
# what Proxmox would generate from the current config
sudo pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
```
**Run both, not just the first.** A rule with an IPv6 destination is compiled
into `ip6tables` and is absent from `iptables -S` — so checking only the v4
table shows rule 12 missing whether it is there or not, and shows it identically
either way.
**What you are looking for.** Proxmox names the per-VM chains after the tap
interface — `tap<vmid>i0-IN` and `tap<vmid>i0-OUT` for the first NIC — and
your rules appear inside them, in the order you added them. In the `-OUT`
chain that means the four pinhole ACCEPTs first, then the four LAN DROPs,
then whatever Proxmox appends for the policy.
your rules appear inside them, in the order you added them. In the v4 `-OUT`
chain that means the four pinhole ACCEPTs first, then the five LAN DROPs, then
whatever Proxmox appends for the policy. The v6 `-OUT` chain holds rule 12 and
none of the pinholes, because every one of them names an IPv4 address.
**What each failure looks like.**
@@ -407,17 +459,18 @@ then whatever Proxmox appends for the policy.
| Chains exist but hold none of your rules | The rules are on a different VM id, or were added at datacenter or node level rather than to this VM |
| A LAN DROP above a pinhole ACCEPT | The ordering trap. The VM will not reach the forge or resolve anything; drag the pinholes above the DROPs |
| Rules present and correct, DNS still failing | The address in rules 5–6 is not the VM's nameserver. The gateway is the usual wrong answer — see "Values that must agree" |
| v4 chain right, `ip6tables -S` empty | Rule 12 was not entered, or the console rejected `::/0` and the error was missed. Nothing about the v4 half will look wrong |
`pve-firewall status` is the quicker sanity check of whether the service is
running and enabled at all, but it says nothing about *these* rules — it is
the datacenter switch it reports on, not your ten lines.
the datacenter switch it reports on, not your twelve lines.
**Both commands describe configuration.** They are worth reading, and they are
not proof: a rule that is loaded and a rule that does what you meant are
different claims. The checks from inside the VM below are the ones that test
behaviour, which is why they follow rather than replace this.
and from inside the VM, once it is up. Three of these must succeed and one
and from inside the VM, once it is up. Three of these must succeed and two
must fail, which is what makes the set diagnostic rather than reassuring:
```sh
@@ -425,16 +478,31 @@ try() { timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null \
&& echo " CONNECTED $1:$2" || echo " blocked $1:$2"; }
gw=$(ip -4 route show default | awk '{print $3; exit}')
dev=$(ip -4 route show default | awk '{for (i=1;i<NF;i++) if ($i=="dev") print $(i+1); exit}')
try "$gw" 443 # must be blocked - your own first hop
try <forge-ip> 443 # must connect
try deb.debian.org 80 # must connect - the public internet
getent hosts deb.debian.org && echo " resolved" || echo " DNS FAILED"
# must find nobody but this VM - rule 12
sudo ping -6 -n -c 2 -W 1 -I "$dev" ff02::1
```
Nothing here needs installing: `bash` opens a TCP connection on its own and
`getent` is part of libc, which matters on a machine you have not finished
setting up yet.
setting up yet. `ping` is `iputils-ping`, which Debian has by default.
**The last one asks the whole LAN who is there, over IPv6.** `ff02::1` is the
all-nodes link-local multicast address and every IPv6 host answers it — and
every Linux host is an IPv6 host, because the `fe80::` address exists whether or
not the network routes v6. Replies from an address that is not this VM's own
mean rule 12 is not in force and the segment is reachable over a family none of
the v4 checks above touch.
Like the gateway probe, it writes down no target: `ff02::1` is the same address
on every network there has ever been, so naming it describes nothing about
yours.
**The first probe targets your gateway, deliberately, and not some other host
on your LAN.** Naming another host would mean writing down something worth
@@ -445,12 +513,15 @@ it sits inside the ranges rules 7 to 10 drop, so with the rules applied it must
be unreachable *as a destination* while traffic *through* it to the internet is
untouched.
Read the four together, because the pattern says which thing is wrong:
Read them together, because the pattern says which thing is wrong:
- **First one connects** → the firewall is not in force at all. Check the
Network device's Firewall checkbox and the datacenter switch.
- **Second or third fails while the fourth works** → a pinhole is below the LAN
DROPs. Check the rule order.
- **Only the last one finds neighbours** → the v4 half is right and rule 12 is
missing. This is the failure that looks like success from every other angle,
which is why it has its own probe.
- **Everything fails** → the VM has no working route or DNS, which is a
networking problem rather than a firewall one. Note that this also makes the
first probe's "blocked" meaningless: silence proves nothing when nothing is
+44 -2
View File
@@ -36,6 +36,10 @@ enable: 1
# Inbound denies by default. Unlike a CI runner, this VM does offer services
# somebody should reach - you work on it - so the exceptions below are longer
# than a runner's and are the part of this file that grows.
#
# It covers both address families and needed nothing said about IPv6, because a
# default policy is not a list of ranges and so cannot leave one out. Compare
# the outbound half, where the same coverage costs an extra rule.
policy_in: DROP
# Outbound ACCEPTs by default, and the LAN is denied explicitly below.
@@ -54,9 +58,12 @@ policy_in: DROP
# not them.
#
# Deny-by-default earns its keep where the protected set is enumerable, which
# is what the four DROPs below are. It does not apply to the internet, which
# is what the IPv4 DROPs below are. It does not apply to the internet, which
# is not enumerable, and treating it as though it were gets you a rule set
# that looks strict and only gets in the way.
#
# The cost of default-ACCEPT is that every range to be denied has to be named,
# and IPv6 is the one it is easiest to forget to name. See the last rule.
policy_out: ACCEPT
log_level_in: nolog
@@ -140,12 +147,47 @@ OUT ACCEPT -dest __RESOLVER_IP__ -p tcp -dport 53 -log nolog
OUT ACCEPT -p udp -dport 67 -log nolog
# --- everything else on your network ----------------------------------------
# The four lines the whole VM exists to make true.
# The six lines the whole VM exists to make true.
OUT DROP -dest 10.0.0.0/8 -log nolog
OUT DROP -dest 172.16.0.0/12 -log nolog
OUT DROP -dest 192.168.0.0/16 -log nolog
# Link-local, which includes cloud metadata services at 169.254.169.254.
OUT DROP -dest 169.254.0.0/16 -log nolog
# Carrier-grade NAT, which is where Tailscale numbers a tailnet. Without this
# line "your network" quietly means the LAN and not the overlay, and the
# overlay is the one that reaches the machines you own somewhere else.
OUT DROP -dest 100.64.0.0/10 -log nolog
# IPv6, all of it, and the reason it is one blanket line rather than four more
# mirroring the ones above.
#
# Those work because the protected set is enumerable: RFC 1918 named three
# ranges and your LAN is inside them. **IPv6 has no RFC 1918.** A v6 network is
# numbered out of a globally routable prefix your ISP delegates, so "the private
# ranges" is not a list that can be written here - fc00::/7 covers unique-local
# addresses most networks do not use, and misses the prefix the machine next
# door is actually addressed on. Enumerating is not available, so the family
# goes.
#
# This is not the trade policy_out declines above. That argument is about the
# internet, and it still holds: the internet is reached over IPv4 by the default
# policy, unbounded and deliberately. What is dropped here is a second path to
# the same place, and a devbox loses nothing by not having it.
#
# It also closes something that is open on a network with no IPv6 at all. Every
# Linux host brings up a fe80:: address on every interface whether or not
# anything routes v6, and a daemon bound to :: answers on it. Without this line
# the five DROPs above can all be in force and the machine beside this one is
# still one `ssh fe80::...%eth0` away.
#
# Do this here rather than by disabling IPv6 in the guest. The guest's own
# settings are administered by whoever holds root on the guest, and this whole
# file exists because that is assumed to be an attacker.
#
# If the console rejects `::/0`, the same coverage is fe80::/10, fc00::/7,
# ff00::/8 and 2000::/3. Prefer the one line: four are four chances to enter
# three.
OUT DROP -dest ::/0 -log nolog
# Nothing follows. The public internet is reached by the default policy, not
# by a list of ports - see the note on policy_out above.
+11 -5
View File
@@ -21,10 +21,15 @@
# and cannot be mistaken for a working value
# - RFC 5737 documentation ranges: 192.0.2.0/24, 198.51.100.0/24,
# 203.0.113.0/24, which exist for exactly this and route nowhere
# - an RFC1918 literal ONLY where the example has to be private to make
# sense, which here means the four ranges the node firewall drops and
# - a private-range literal ONLY where the example has to be private to make
# sense, which here means the five ranges the node firewall drops and
# nothing else. Those name every LAN rather than anybody's.
#
# IPv6 literals are not checked. The one this repository commits is ::/0, which
# names no network at all, and there is no v6 equivalent of the RFC 1918 shape
# this guard looks for - which is itself the reason rule 12 drops the family
# whole rather than enumerating it.
#
# This repository is private today and public later, which changes nothing:
# redaction before the first commit is free, and redaction afterwards means
# rewriting history and living with whatever was already cloned.
@@ -42,10 +47,11 @@ setup() {
255.255.255.255 # limited broadcast, in the DHCP rebind note
169.254.169.254 # the cloud metadata address, named as a target
10.0.0.0/8 # the four RFC1918 + link-local ranges the node
172.16.0.0/12 # firewall drops. Ranges, not hosts: they name
192.168.0.0/16 # every LAN rather than anybody's
10.0.0.0/8 # the five ranges the node firewall drops. Ranges,
172.16.0.0/12 # not hosts: they name every LAN rather than
192.168.0.0/16 # anybody's
169.254.0.0/16
100.64.0.0/10 # carrier-grade NAT, where a tailnet is numbered
192.0.2.0/24 # the three RFC 5737 documentation ranges, named
198.51.100.0/24 # in the guidance above as what to reach for
+127
View File
@@ -17,6 +17,14 @@ setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
source "$REPO/bin/dejima-devbox-audit"
default_gateway() { printf '192.0.2.1\n'; }
# The two IPv6 functions that touch the network, stubbed to the contained
# answer for the same reason default_gateway is: every test would otherwise
# ping the segment it happens to be running on.
#
# Their caller, ipv6_verdict, is deliberately NOT stubbed - see the note
# below on positive_controls_ok for what that mistake looks like.
ipv6_routable_addr() { printf ''; }
ipv6_neighbour_replies() { printf '0\n'; }
# positive_controls_ok is deliberately NOT stubbed here. Stubbing it in
# setup shadowed the real function for the tests that exist to check it,
# and they passed against the stub. Each test that needs it faked says so.
@@ -129,6 +137,125 @@ setup() {
[[ "$output" == *REJECT* ]]
}
# --- the other address family ------------------------------------------------
#
# The v4 checks above cannot see this at all: a guest reaching the LAN over IPv6
# has an unreachable v4 gateway and a reachable forge, which is exactly what a
# pass looks like.
@test "a global IPv6 address is a failure on its own, with no probe needed" {
# It got there by SLAAC or DHCPv6, so an advertisement reached this guest and
# a reply left it. That is the node not dropping v6, evidenced rather than
# inferred.
ipv6_routable_addr() { printf '2001:db8::5/64\n'; }
run ipv6_verdict
[ "$output" = FAIL ]
}
@test "a link-local address alone is not a failure, because it is always there" {
# The kernel brings one up whether or not anything routes v6. Reading it as
# reach would fail every correctly contained VM, which is why the address
# check is scoped to global.
run ipv6_verdict
[ "$output" = PASS ]
}
@test "a neighbour answering on the link fails even with no v6 address here" {
# The case a v4 reading misses entirely: nothing routes v6, every host on the
# segment is still reachable at fe80:: and answering for it.
ipv6_neighbour_replies() { printf '2\n'; }
run ipv6_verdict
[ "$output" = FAIL ]
}
@test "our own reply to our own multicast is not a neighbour" {
# We answer ff02::1 too. Counting that reports every contained VM as a
# failure, and it is the first thing this probe gets wrong if left alone.
out='64 bytes from fe80::1%eth0: icmp_seq=1 ttl=64 time=0.052 ms'
run ipv6_replies_from_others "$out" 'fe80::1'
[ "$output" = 0 ]
}
@test "a neighbour's reply alongside our own is counted" {
out='64 bytes from fe80::1%eth0: icmp_seq=1 ttl=64 time=0.052 ms
64 bytes from fe80::99%eth0: icmp_seq=1 ttl=64 time=1.21 ms (DUP!)'
run ipv6_replies_from_others "$out" 'fe80::1'
[ "$output" = 1 ]
}
@test "silence from the whole segment counts nobody" {
run ipv6_replies_from_others '' 'fe80::1'
[ "$output" = 0 ]
}
@test "a v6 failure fails the whole reach check even when the v4 half passes" {
gateway_verdict() { printf 'PASS\n'; }
ipv6_neighbour_replies() { printf '1\n'; }
run cmd_reach
[ "$status" -ne 0 ]
[[ "$output" == *FAIL* ]]
}
@test "an INDETERMINATE v4 half is not overwritten by a v6 failure" {
# Both are non-passes and both are reported, but INDETERMINATE is the more
# specific thing to have said about the v4 half and survives as the verdict.
gateway_verdict() { printf 'INDETERMINATE\n'; }
ipv6_neighbour_replies() { printf '1\n'; }
run cmd_reach
[ "$status" -ne 0 ]
[[ "$output" == *INDETERMINATE* ]]
}
@test "the v6 remedy points at the node, not at a sysctl in the guest" {
# Someone reading this failure will reach for disable_ipv6 first. It is the
# wrong layer for the same reason the rest of the firewall is not in here.
gateway_verdict() { printf 'PASS\n'; }
ipv6_neighbour_replies() { printf '1\n'; }
run cmd_reach
[[ "$output" == *guest* ]]
}
@test "a probe that could not run is indeterminate, not a pass" {
# No ping installed, or no interface to ask on. Nobody answered because
# nobody was asked, and the two are not the same reading.
ipv6_neighbour_replies() { return 1; }
run ipv6_verdict
[ "$output" = INDETERMINATE ]
}
@test "an unrunnable probe still fails a host that has a global v6 address" {
# The address needs no probe: it is evidence rather than inference, so the
# answer is definite even where nothing could be sent.
ipv6_routable_addr() { printf '2001:db8::5/64\n'; }
ipv6_neighbour_replies() { return 1; }
run ipv6_verdict
[ "$output" = FAIL ]
}
@test "an unrunnable v6 probe fails the reach check rather than passing it" {
gateway_verdict() { printf 'PASS\n'; }
ipv6_neighbour_replies() { return 1; }
run cmd_reach
[ "$status" -ne 0 ]
[[ "$output" == *INDETERMINATE* ]]
}
@test "an unrunnable v6 probe names the package that would fix it" {
gateway_verdict() { printf 'PASS\n'; }
ipv6_neighbour_replies() { return 1; }
run cmd_reach
[[ "$output" == *iputils-ping* ]]
}
@test "a v6 FAIL outranks a v6-probe INDETERMINATE for the reported verdict" {
# Both are non-passes, but FAIL is definite and INDETERMINATE is not, so a
# run that found a neighbour must not be softened by anything.
gateway_verdict() { printf 'PASS\n'; }
ipv6_neighbour_replies() { printf '1\n'; }
run cmd_reach
[[ "$output" == *FAIL* ]]
}
# --- radius report ----------------------------------------------------------
#
# This one inventories the box rather than the network, and the distinction is
+19 -3
View File
@@ -28,12 +28,27 @@ lineno() { grep -nE "$1" "$TPL" | head -1 | cut -d: -f1; }
[ "$last_accept" -lt "$first_drop" ]
}
@test "all four dropped ranges are present" {
for r in '10\.0\.0\.0/8' '172\.16\.0\.0/12' '192\.168\.0\.0/16' '169\.254\.0\.0/16'; do
@test "all six dropped ranges are present" {
for r in '10\.0\.0\.0/8' '172\.16\.0\.0/12' '192\.168\.0\.0/16' \
'169\.254\.0\.0/16' '100\.64\.0\.0/10' '::/0'; do
grep -qE "^OUT DROP -dest $r" "$TPL"
done
}
@test "IPv6 is dropped, because none of the v4 ranges above match any of it" {
# The failure this pins is silent from every other angle: the v4 rules can be
# entered perfectly and every host on the segment stays reachable at fe80::,
# on a network that routes no IPv6 at all.
grep -qE '^OUT DROP -dest ::/0' "$TPL"
}
@test "the outbound policy is the only one that needed IPv6 spelled out" {
# policy_in: DROP is a policy rather than a list, so it cannot leave an
# address family out. policy_out: ACCEPT can, and did.
grep -qE '^policy_in: DROP' "$TPL"
! grep -qE '^IN DROP -dest ::/0' "$TPL"
}
@test "the forge is reachable on both 443 and 22" {
# 22 is the one a CI runner never needed. A devbox pushes over SSH, and
# without this rule that push hangs against the DROPs below.
@@ -58,10 +73,11 @@ lineno() { grep -nE "$1" "$TPL" | head -1 | cut -d: -f1; }
# Placeholders only. addresses.bats catches leaks repository-wide; this
# catches a rule that works on the network it was written on and silently
# on no other.
run grep -nE '^(IN|OUT) (ACCEPT|DROP).*-(source|dest) [0-9]' "$TPL"
run grep -nE '^(IN|OUT) (ACCEPT|DROP).*-(source|dest) [0-9:]' "$TPL"
for line in "${lines[@]}"; do
case "$line" in
*10.0.0.0/8*|*172.16.0.0/12*|*192.168.0.0/16*|*169.254.0.0/16*) ;;
*100.64.0.0/10*|*::/0*) ;;
*) echo "rule names a literal address: $line"; false ;;
esac
done