From e9a24b14e3ddeb7d4c69fe16a54d51562600489e Mon Sep 17 00:00:00 2001 From: abrown38 Date: Sun, 25 Oct 2026 01:00:00 +0000 Subject: [PATCH] Refuse link-local addresses in the antispoof lists fe80::/10 in the IPv6 antispoof list (or your prefixes) dropped the ISP's router adverts and DHCPv6 replies in the early chain, before the ICMPv6 and DHCPv6 client built-ins could accept them: the router lost its IPv6 default route. Validation now refuses it (antispoof_link_local), the settings page says so, and the importer leaves it out with a note. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++++ docs/rules.md | 7 +++++- docs/validation.md | 1 + internal/importer/importer.go | 10 ++++++++- internal/importer/importer_test.go | 23 ++++++++++++++++++++ internal/model/addr.go | 3 +++ internal/validate/validate.go | 2 ++ internal/validate/validate_test.go | 3 +++ internal/web/static/assets/pages/settings.js | 2 +- 9 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9745bd9..efe0831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,13 @@ All notable changes to Housecarl are documented here. The format follows ### Fixed +- Link-local addresses (`fe80::/10`) are refused in the IPv6 antispoof + list and in your prefixes (`antispoof_link_local`). Dropping them on + external zones dropped the ISP's router adverts and DHCPv6 replies + before the built-ins could accept them: the IPv6 default route expired + and, later, the delegated prefix would have. A configuration saved + with one keeps being applied, but the next change is refused until it + is taken out. `hc import nft` leaves them out, with a note. - An IPv4-mapped prefix (`::ffff:0:0/96`) is refused with an error that says so; it was refused as "has host bits set; the prefix is invalid Prefix". diff --git a/docs/rules.md b/docs/rules.md index a4e40b4..0eb00b4 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -347,7 +347,12 @@ UI), and `hc ruleset` shows them: **Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated IPv6 prefix and public addresses: nothing from outside should come from them. The antispoof lists can be changed with `--antispoof-v4` and -`--antispoof-v6`. +`--antispoof-v6`. Link-local addresses (`fe80::/10`) are refused in the +IPv6 list and in your prefixes (`antispoof_link_local`): your ISP's +router sends its router adverts and DHCPv6 replies from them, and +antispoofing runs in the early chain, before the ICMPv6 and DHCPv6 +client built-ins could accept them, so the router would lose its IPv6 +default route and, later, its delegated prefix. The **flowtable** (`--flowtable wan --flowtable eth1`) lets established TCP and UDP connections forwarded between those devices skip the rules, diff --git a/docs/validation.md b/docs/validation.md index d95d668..64b11d6 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -31,6 +31,7 @@ are unique per kind, ignoring case. |---|---|---| | `policy` | error | a policy that isn't `accept` or `drop` | | `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) | +| `antispoof_link_local` | error | an own prefix or IPv6 antispoof entry holds link-local addresses (`fe80::/10`): the ISP's router adverts and DHCPv6 replies come from them, so dropping them on external zones loses the IPv6 default route and, later, the delegated prefix. A configuration saved before this check keeps being applied; the next change is refused until the entry is taken out | | `flowtable_device` | error | a flowtable device with a wildcard: name each device | | `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does | | `probation_range` | error | the probation isn't 10 to 3600 seconds | diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 599202f..31d4bda 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -1502,7 +1502,15 @@ func (im *importer) builtinOf(chain string, p *parsed) string { } set := &im.st.Settings if p.family == "ipv6" { - set.OwnPrefixes = append(set.OwnPrefixes, p.src...) + for _, s := range p.src { + // Dropping link-local on an external zone loses the ISP's + // router adverts and DHCPv6 replies. + if a, err := model.ParseAddress(s); err == nil && a.Overlaps(model.LinkLocal6) { + im.note("antispoofing: %s left out: it holds link-local addresses, which the ISP's router adverts and DHCPv6 replies come from", s) + continue + } + set.OwnPrefixes = append(set.OwnPrefixes, s) + } im.st.Settings.AntispoofV6 = nil } else { set.AntispoofV4 = p.src diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index 6a804d9..945e932 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -321,3 +321,26 @@ func TestImportMeters(t *testing.T) { } } } + +// TestImportAntispoofLinkLocal: link-local in an IPv6 antispoof drop is +// left out (with a note), since it would drop the ISP's router adverts. +func TestImportAntispoofLinkLocal(t *testing.T) { + js, _ := os.ReadFile("testdata/router.json") + txt, _ := os.ReadFile("testdata/router.txt") + defs, _ := os.ReadFile("testdata/router.nft") + own := `{"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}` + if !strings.Contains(string(js), own) { + t.Fatal("router.json has no own-prefix drop") + } + js = []byte(strings.Replace(string(js), own, `{"set": [`+own+`, {"prefix": {"addr": "fe80::", "len": 10}}]}`, 1)) + res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)}) + if err != nil { + t.Fatal(err) + } + if p := res.State.Settings.OwnPrefixes; len(p) != 1 || p[0] != "2a02:8013:7f3d::/48" { + t.Errorf("own prefixes %v", p) + } + if !strings.Contains(strings.Join(res.Notes, "\n"), "fe80::/10 left out") { + t.Errorf("no note: %q", res.Notes) + } +} diff --git a/internal/model/addr.go b/internal/model/addr.go index 4205da7..2393754 100644 --- a/internal/model/addr.go +++ b/internal/model/addr.go @@ -6,6 +6,9 @@ import ( "strings" ) +// LinkLocal6 is IPv6's link-local prefix. +var LinkLocal6 = netip.MustParsePrefix("fe80::/10") + // Address is a parsed address entry: one address, a prefix or a range. type Address struct { Prefix netip.Prefix // a single address is a /32 or /128 diff --git a/internal/validate/validate.go b/internal/validate/validate.go index c4f50d8..9f759cb 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -174,6 +174,8 @@ func (c *checker) settings() { } if l.v6 != 2 && a.IsV6() != (l.v6 == 1) { c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e) + } else if a.Overlaps(model.LinkLocal6) { + c.add(Error, "antispoof_link_local", "settings", "%s: %s holds link-local addresses (fe80::/10), which your ISP's router sends its router adverts and DHCPv6 replies from: dropping them on external zones loses the IPv6 default route, and later the delegated prefix. Link-local addresses never come from the internet anyway; take %s out", l.name, e, e) } } } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 6e276f6..b63dd18 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -68,6 +68,9 @@ func TestCodes(t *testing.T) { {"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }}, {"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }}, {"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }}, + {"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"fc00::/7", "fe80::/10"} }}, + {"antispoof_link_local", func(st *model.State) { st.Settings.OwnPrefixes = []string{"fe80::/64"} }}, + {"antispoof_link_local", func(st *model.State) { st.Settings.AntispoofV6 = []string{"::/0"} }}, {"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }}, {"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }}, {"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }}, diff --git a/internal/web/static/assets/pages/settings.js b/internal/web/static/assets/pages/settings.js index 818ebf7..f71649a 100644 --- a/internal/web/static/assets/pages/settings.js +++ b/internal/web/static/assets/pages/settings.js @@ -54,7 +54,7 @@ export async function render(el) { field("Built-ins", h("div", { class: "checks" }, bis.map(([, c]) => c))), field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."), field("Antispoof IPv4", v4), - field("Antispoof IPv6", v6), + field("Antispoof IPv6", v6, "Never link-local (fe80::/10): your ISP's router adverts and DHCPv6 replies come from it."), field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."), field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."), h("label"), manual,