net/packet,wgengine/filter: handle IPv6 fragment extension header

decode6 didn't parse the IPv6 Fragment extension header (Next Header 44),
so any source-fragmented IPv6 packet was classified as an unknown protocol
and matched no ACL rule. The filter then silently dropped it and counted it
as an "acl" drop, even on allow-all tailnets, blackholing large UDP (DNS,
WebRTC, etc.) over a tailnet's IPv6 addresses. IPv4 fragments were already
handled by decode4.

Parse the fragment header the same way: read the first fragment's transport
ports so the filter matches it like an unfragmented packet, pass later
fragments through as ipproto.Fragment, and reject overlapping-fragment
offsets (RFC 1858) and first fragments too short to hold the transport
header as unknown.

Fixes #20083

Signed-off-by: Steve Avery <hello@stevenavery.com>
This commit is contained in:
Steve Avery
2026-06-15 11:18:00 -07:00
committed by James Tucker
parent f002f6bb3a
commit 4c4ec3d468
3 changed files with 257 additions and 11 deletions
+58
View File
@@ -254,6 +254,8 @@ func TestNoAllocs(t *testing.T) {
{"udp6_in", in, udp6Packet},
{"udp4_out", out, udp4Packet},
{"udp6_out", out, udp6Packet},
{"frag6_first_in", in, udp6FirstFragment},
{"frag6_nonfirst_in", in, udp6NonFirstFragment},
}
for _, test := range tests {
@@ -377,6 +379,41 @@ func BenchmarkFilter(b *testing.B) {
}
}
// udp6FirstFragment is the first fragment (offset 0) of a source-fragmented
// IPv6 UDP datagram from 2001::5 to [2001::1]:443. decode6 reads its ports past
// the 8-byte Fragment extension header so the filter can match it like an
// unfragmented packet.
var udp6FirstFragment = []byte{
// IPv6 header. Next header = 44 (Fragment), payload len = 24.
0x60, 0x00, 0x00, 0x00, 0x00, 0x18, 0x2c, 0x40,
// Src: 2001::5
0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
// Dst: 2001::1
0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
// Fragment header: NextHeader=UDP, Reserved, Offset=0 + M=1, Identification.
0x11, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef,
// UDP header: sport 1234, dport 443.
0x04, 0xd2, 0x01, 0xbb, 0x00, 0x10, 0x00, 0x00,
// Payload.
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
}
// udp6NonFirstFragment is a later fragment (nonzero offset, no transport
// header) of an IPv6 datagram. decode6 classifies it as ipproto.Fragment,
// which pre() passes through regardless of ACL, exactly as for IPv4.
var udp6NonFirstFragment = []byte{
// IPv6 header. Next header = 44 (Fragment), payload len = 16.
0x60, 0x00, 0x00, 0x00, 0x00, 0x10, 0x2c, 0x40,
// Src: 2001::5
0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
// Dst: 2001::1
0x20, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
// Fragment header: NextHeader=UDP, Reserved, Offset=185 blocks + M=0, Identification.
0x11, 0x00, 0x05, 0xc8, 0xde, 0xad, 0xbe, 0xef,
// Payload continuation (no transport header).
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
}
func TestPreFilter(t *testing.T) {
packets := []struct {
desc string
@@ -389,6 +426,7 @@ func TestPreFilter(t *testing.T) {
{"short-junk", Drop, usermetric.ReasonTooShort, raw4default(ipproto.Unknown, 10)},
{"long-junk", Drop, usermetric.ReasonUnknownProtocol, raw4default(ipproto.Unknown, 21)},
{"fragment", Accept, "", raw4default(ipproto.Fragment, 40)},
{"fragment6", Accept, "", udp6NonFirstFragment},
{"tcp", noVerdict, "", raw4default(ipproto.TCP, 0)},
{"udp", noVerdict, "", raw4default(ipproto.UDP, 0)},
{"icmp", noVerdict, "", raw4default(ipproto.ICMPv4, 0)},
@@ -404,6 +442,26 @@ func TestPreFilter(t *testing.T) {
}
}
// TestRunInIPv6FirstFragment checks that the first fragment of a
// source-fragmented IPv6 datagram is matched on its ports and accepted like an
// unfragmented packet, rather than being dropped as an unknown protocol (the
// bug where v6 fragments were silently counted as "acl" drops).
func TestRunInIPv6FirstFragment(t *testing.T) {
f := newFilter(t.Logf)
var p packet.Parsed
p.Decode(udp6FirstFragment)
// The fragment header must be parsed through to the real sub-protocol;
// otherwise no ACL rule can match it.
if p.IPProto != ipproto.UDP {
t.Fatalf("decoded IPProto = %v, want UDP (fragment header not parsed)", p.IPProto)
}
// 2001::5 => [2001::1]:443 is permitted by the "::/0 => ::/0:443" rule.
if got := f.RunIn(&p, 0); got != Accept {
t.Errorf("RunIn(first fragment) = %v, want Accept", got)
}
}
func TestOmitDropLogging(t *testing.T) {
tests := []struct {
name string