From 23043dcce655f98baa63d649a5b0f8ebe110d336 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 23:33:57 +0200 Subject: [PATCH] fix(relay): filter remote IPv6 fragments The relay now looks through ordinary IPv6 extension headers to catch remote DHCPv6 server replies and Router Advertisements. IPv6 fragments are still an evasion risk because later fragments may not contain the upper-layer ports or ICMPv6 type that the relay safety policy checks. For the MVP, make that boundary conservative: remote-client IPv6 fragments are filtered before they can reach the physical LAN. LAN-origin fragments are still allowed to flow back to remote clients, so this does not block ordinary LAN traffic returning through the gateway. Add a dedicated diagnostics drop reason so logs explain the policy clearly. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay -p lanparty-obs - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: MVP relay L2 safety filters --- README.md | 6 ++-- crates/lanparty-obs/src/lib.rs | 1 + crates/lanparty-relay/src/lib.rs | 54 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d2b065b..e403b5c 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,9 @@ Public relay binary and relay-owned room state: - live Ethernet datagram forwarding with no ingress reflection - per-peer egress budget checks against the negotiated datagram size - reliable `PeerJoined`/`PeerLeft` notifications to existing room peers -- L2 safety filters for invalid-source, jumbo, switch-control, IPv4/IPv6 - DHCP-server, and IPv6-RA frames, including frames behind ordinary IPv6 - extension headers +- L2 safety filters for invalid-source, jumbo, switch-control, remote IPv6 + fragments, IPv4/IPv6 DHCP-server, and IPv6-RA frames, including frames behind + ordinary IPv6 extension headers - client broadcast/multicast, unknown-unicast, and total bandwidth limiting - malformed peer datagram disconnect threshold - peer stats control events retained for relay diagnostics diff --git a/crates/lanparty-obs/src/lib.rs b/crates/lanparty-obs/src/lib.rs index c6094e9..e6c1a26 100644 --- a/crates/lanparty-obs/src/lib.rs +++ b/crates/lanparty-obs/src/lib.rs @@ -36,6 +36,7 @@ pub enum DropReason { ControlPlaneEtherType, DhcpServerReply, Ipv6RouterAdvertisement, + Ipv6Fragment, DatagramBudget, UnknownDestination, RateLimit, diff --git a/crates/lanparty-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index bf99488..8dd75eb 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -740,6 +740,10 @@ fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option) -> bool { .is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) } +fn is_ipv6_fragment(frame: EthernetFrame<'_>) -> bool { + let bytes = frame.bytes(); + if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { + return false; + } + + let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; + is_ipv6_packet(ipv6) && ipv6_has_extension_header(ipv6, IPV6_NEXT_HEADER_FRAGMENT) +} + fn is_ipv6_packet(ipv6: &[u8]) -> bool { ipv6.first().is_some_and(|first| first >> 4 == 6) } +fn ipv6_has_extension_header(ipv6: &[u8], expected_next_header: u8) -> bool { + ipv6_upper_layer_payload_offset(ipv6, expected_next_header).is_some() +} + fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option { let mut next_header = *ipv6.get(6)?; let mut offset = 40; @@ -971,6 +989,16 @@ mod tests { ) } + fn ipv6_fragment_payload(next_header: u8, fragment_payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(8 + fragment_payload.len()); + packet.push(next_header); + packet.push(0); + packet.extend_from_slice(&0_u16.to_be_bytes()); + packet.extend_from_slice(&1_u32.to_be_bytes()); + packet.extend_from_slice(fragment_payload); + packet + } + fn ipv6_payload(next_header: u8, upper_payload: &[u8]) -> Vec { let mut packet = vec![0; 40]; packet[0] = 0x60; @@ -1551,6 +1579,32 @@ mod tests { assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); } + #[test] + fn filters_remote_ipv6_fragments_but_allows_lan_fragments() { + let mut registry = RoomRegistry::default(); + let gateway = registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let destination = MacAddr::new([0x33, 0x33, 0, 0, 0, 1]); + let payload = ipv6_payload( + IPV6_NEXT_HEADER_FRAGMENT, + &ipv6_fragment_payload(IP_PROTOCOL_UDP, b"partial upper payload"), + ); + let client_frame = ethernet_with_payload(destination, mac(1), ETHERTYPE_IPV6, &payload); + let gateway_frame = + ethernet_with_payload(destination, physical_mac(), ETHERTYPE_IPV6, &payload); + + let client_decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &client_frame) + .unwrap(); + let gateway_decision = registry + .forward_ethernet(&room(), gateway.peer().peer_id(), &gateway_frame) + .unwrap(); + + assert_filtered(&client_decision, DropReason::Ipv6Fragment); + assert_eq!(gateway_decision.action(), FrameAction::Forwarded); + assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); + } + #[test] fn filters_remote_ipv6_router_advertisements() { let mut registry = RoomRegistry::default();