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
This commit is contained in:
2026-05-21 23:33:57 +02:00
parent 756ba5f094
commit 23043dcce6
3 changed files with 58 additions and 3 deletions
+3 -3
View File
@@ -102,9 +102,9 @@ Public relay binary and relay-owned room state:
- live Ethernet datagram forwarding with no ingress reflection - live Ethernet datagram forwarding with no ingress reflection
- per-peer egress budget checks against the negotiated datagram size - per-peer egress budget checks against the negotiated datagram size
- reliable `PeerJoined`/`PeerLeft` notifications to existing room peers - reliable `PeerJoined`/`PeerLeft` notifications to existing room peers
- L2 safety filters for invalid-source, jumbo, switch-control, IPv4/IPv6 - L2 safety filters for invalid-source, jumbo, switch-control, remote IPv6
DHCP-server, and IPv6-RA frames, including frames behind ordinary IPv6 fragments, IPv4/IPv6 DHCP-server, and IPv6-RA frames, including frames behind
extension headers ordinary IPv6 extension headers
- client broadcast/multicast, unknown-unicast, and total bandwidth limiting - client broadcast/multicast, unknown-unicast, and total bandwidth limiting
- malformed peer datagram disconnect threshold - malformed peer datagram disconnect threshold
- peer stats control events retained for relay diagnostics - peer stats control events retained for relay diagnostics
+1
View File
@@ -36,6 +36,7 @@ pub enum DropReason {
ControlPlaneEtherType, ControlPlaneEtherType,
DhcpServerReply, DhcpServerReply,
Ipv6RouterAdvertisement, Ipv6RouterAdvertisement,
Ipv6Fragment,
DatagramBudget, DatagramBudget,
UnknownDestination, UnknownDestination,
RateLimit, RateLimit,
+54
View File
@@ -740,6 +740,10 @@ fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<Dr
return Some(DropReason::Ipv6RouterAdvertisement); return Some(DropReason::Ipv6RouterAdvertisement);
} }
if ingress_role == Role::Client && is_ipv6_fragment(frame) {
return Some(DropReason::Ipv6Fragment);
}
None None
} }
@@ -823,10 +827,24 @@ fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool {
.is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) .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 { fn is_ipv6_packet(ipv6: &[u8]) -> bool {
ipv6.first().is_some_and(|first| first >> 4 == 6) 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<usize> { fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option<usize> {
let mut next_header = *ipv6.get(6)?; let mut next_header = *ipv6.get(6)?;
let mut offset = 40; let mut offset = 40;
@@ -971,6 +989,16 @@ mod tests {
) )
} }
fn ipv6_fragment_payload(next_header: u8, fragment_payload: &[u8]) -> Vec<u8> {
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<u8> { fn ipv6_payload(next_header: u8, upper_payload: &[u8]) -> Vec<u8> {
let mut packet = vec![0; 40]; let mut packet = vec![0; 40];
packet[0] = 0x60; packet[0] = 0x60;
@@ -1551,6 +1579,32 @@ mod tests {
assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); 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] #[test]
fn filters_remote_ipv6_router_advertisements() { fn filters_remote_ipv6_router_advertisements() {
let mut registry = RoomRegistry::default(); let mut registry = RoomRegistry::default();