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
+70 -11
View File
@@ -19,6 +19,16 @@ const unknown = ipproto.Unknown
// RFC1858: prevent overlapping fragment attacks.
const minFragBlks = (60 + 20) / 8 // max IPv4 header + basic TCP header in fragment blocks (8 bytes each)
// ip6FragHeader is the IANA protocol number for the IPv6 Fragment extension
// header ("IPv6-Frag"). It appears as the base header's Next Header value on a
// fragmented packet; decode6 steps over it to reach the real sub-protocol.
// This is distinct from ipproto.Fragment (0xFF), our internal sentinel for a
// non-first fragment whose sub-protocol header is not present.
const ip6FragHeader ipproto.Proto = 44
// ip6FragHeaderLength is the length of the IPv6 Fragment extension header.
const ip6FragHeaderLength = 8
type TCPFlag uint8
const (
@@ -272,19 +282,25 @@ func (q *Parsed) decode6(b []byte) {
q.Src = withIP(q.Src, srcIP)
q.Dst = withIP(q.Dst, dstIP)
// We don't support any IPv6 extension headers. Don't try to
// be clever. Therefore, the IP subprotocol always starts at
// byte 40.
// The IP subprotocol normally begins right after the 40-byte IPv6
// header. The one extension header we parse is the Fragment header
// (Next Header 44): a host source-fragmenting a datagram larger than
// the tun MTU emits these, and RFC 8200 section 4.5 requires the
// receiver to reassemble them, so we must let them through. For the
// first fragment we step over the fragment header and read the real
// sub-protocol's ports exactly as decode4 does; later fragments are
// marked ipproto.Fragment and passed through by the filter.
//
// Note that this means we don't support fragmentation in
// IPv6. This is fine, because IPv6 strongly mandates that you
// should not fragment, which makes fragmentation on the open
// internet extremely uncommon.
//
// This also means we don't support IPSec headers (AH/ESP), or
// IPv6 jumbo frames. Those will get marked Unknown and
// dropped.
// We still don't parse any other extension headers (hop-by-hop,
// routing, destination options) or IPSec headers (AH/ESP), nor a
// Fragment header that isn't the base header's immediate Next Header.
// Those get marked Unknown and dropped.
q.subofs = 40
if q.IPProto == ip6FragHeader {
if !q.decode6Fragment(b) {
return
}
}
sub := b[q.subofs:]
sub = sub[:len(sub):len(sub)] // help the compiler do bounds check elimination
@@ -341,6 +357,49 @@ func (q *Parsed) decode6(b []byte) {
}
}
// decode6Fragment parses the IPv6 Fragment extension header at q.subofs in b
// (q.subofs is the 40-byte base header length when called). It reports whether
// decode6 should continue into the sub-protocol switch: true only for the
// first fragment, where q.subofs and q.IPProto have been advanced to the real
// transport header so its ports get parsed like an unfragmented packet. For
// later or malformed fragments it sets q.IPProto itself (ipproto.Fragment to
// pass through, or unknown to drop) and returns false.
func (q *Parsed) decode6Fragment(b []byte) (continueDecode bool) {
// The fragment header is 8 bytes: Next Header, Reserved, a 13-bit
// Fragment Offset (in 8-byte blocks) plus a More-Fragments flag, and a
// 32-bit Identification.
if len(b) < q.subofs+ip6FragHeaderLength {
q.IPProto = unknown
return false
}
frag := b[q.subofs:]
nextHdr := ipproto.Proto(frag[0])
fragOfs := binary.BigEndian.Uint16(frag[2:4]) >> 3
// Step over the fragment header. The real sub-protocol (first fragment)
// or the continued payload (later fragments) begins here.
q.subofs += ip6FragHeaderLength
if fragOfs == 0 {
// First fragment: decode the real sub-protocol's header so the
// filter can match on its ports. The switch in decode6 performs
// the per-protocol bounds checks, including rejecting a first
// fragment too short to hold the transport header.
q.IPProto = nextHdr
return true
}
// Later fragment: there's no sub-protocol header to read. Reject offsets
// small enough to overlap the transport header (RFC 1858, same guard as
// decode4); otherwise pass it through as a fragment.
if fragOfs < minFragBlks {
q.IPProto = unknown
return false
}
q.IPProto = ipproto.Fragment
return false
}
func (q *Parsed) IP4Header() IP4Header {
if q.IPVersion != 4 {
panic("IP4Header called on non-IPv4 Parsed")