From b310a33bb2b69793efa2023c2727b1e292a349f0 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 23:26:07 +0200 Subject: [PATCH] fix(relay): detect IPv6 RAs behind extension headers Remote clients must not be able to inject IPv6 Router Advertisements onto the LAN. The relay already filtered direct ICMPv6 RA packets, but the check only looked at the IPv6 base header's immediate next-header value. A client could hide the ICMPv6 RA behind ordinary IPv6 extension headers and bypass that MVP safety policy. Walk the IPv6 extension-header chain for hop-by-hop, routing, destination options, fragment, and AH headers before checking the ICMPv6 message type. This keeps non-RA ICMPv6 traffic eligible for normal relay forwarding while closing the obvious RA evasion path. Document the stronger relay safety boundary in the README. Test Plan: - cargo fmt --check - cargo test -p lanparty-relay - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: MVP relay L2 safety filters --- README.md | 2 +- crates/lanparty-relay/src/lib.rs | 133 +++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index db5ce79..454e0a3 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Public relay binary and relay-owned room state: - 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, DHCP-server, - and IPv6-RA frames + and IPv6-RA frames, including RAs 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-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 3fcf86a..4e5ac80 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -33,6 +33,12 @@ const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; const ETHERTYPE_LLDP: u16 = 0x88cc; const ETHERTYPE_IPV6: u16 = 0x86dd; const IPV4_PROTOCOL_UDP: u8 = 17; +const IPV6_NEXT_HEADER_HOP_BY_HOP: u8 = 0; +const IPV6_NEXT_HEADER_ROUTING: u8 = 43; +const IPV6_NEXT_HEADER_FRAGMENT: u8 = 44; +const IPV6_NEXT_HEADER_AH: u8 = 51; +const IPV6_NEXT_HEADER_NO_NEXT: u8 = 59; +const IPV6_NEXT_HEADER_DESTINATION_OPTIONS: u8 = 60; const IPV6_NEXT_HEADER_ICMPV6: u8 = 58; const DHCP_SERVER_PORT: u16 = 67; const DHCP_CLIENT_PORT: u16 = 68; @@ -774,17 +780,60 @@ fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { let bytes = frame.bytes(); - if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 + 1 { + if frame.ethertype_or_len() != ETHERTYPE_IPV6 || bytes.len() < ETHERNET_HEADER_LEN + 40 { return false; } let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; let version = ipv6[0] >> 4; - let next_header = ipv6[6]; + if version != 6 { + return false; + } - version == 6 - && next_header == IPV6_NEXT_HEADER_ICMPV6 - && ipv6[40] == ICMPV6_ROUTER_ADVERTISEMENT + let Some(icmpv6_offset) = ipv6_icmpv6_payload_offset(ipv6) else { + return false; + }; + + ipv6.get(icmpv6_offset) + .is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) +} + +fn ipv6_icmpv6_payload_offset(ipv6: &[u8]) -> Option { + let mut next_header = *ipv6.get(6)?; + let mut offset = 40; + + loop { + match next_header { + IPV6_NEXT_HEADER_ICMPV6 => return Some(offset), + IPV6_NEXT_HEADER_NO_NEXT => return None, + IPV6_NEXT_HEADER_HOP_BY_HOP + | IPV6_NEXT_HEADER_ROUTING + | IPV6_NEXT_HEADER_DESTINATION_OPTIONS => { + let extension = ipv6.get(offset..offset.checked_add(2)?)?; + next_header = extension[0]; + let extension_len = (usize::from(extension[1]) + 1) * 8; + offset = offset.checked_add(extension_len)?; + if offset > ipv6.len() { + return None; + } + } + IPV6_NEXT_HEADER_FRAGMENT => { + let fragment = ipv6.get(offset..offset.checked_add(8)?)?; + next_header = fragment[0]; + offset = offset.checked_add(8)?; + } + IPV6_NEXT_HEADER_AH => { + let extension = ipv6.get(offset..offset.checked_add(2)?)?; + next_header = extension[0]; + let extension_len = (usize::from(extension[1]) + 2) * 4; + offset = offset.checked_add(extension_len)?; + if offset > ipv6.len() { + return None; + } + } + _ => return None, + } + } } fn reject_control_error(error: ControlError) -> Reject { @@ -855,10 +904,37 @@ mod tests { } fn ipv6_router_advertisement_payload() -> Vec { - let mut packet = vec![0; 41]; + ipv6_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT]) + } + + fn ipv6_router_advertisement_after_destination_options_payload() -> Vec { + ipv6_payload( + IPV6_NEXT_HEADER_DESTINATION_OPTIONS, + &ipv6_extension_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT]), + ) + } + + fn ipv6_echo_request_after_destination_options_payload() -> Vec { + ipv6_payload( + IPV6_NEXT_HEADER_DESTINATION_OPTIONS, + &ipv6_extension_payload(IPV6_NEXT_HEADER_ICMPV6, &[128]), + ) + } + + fn ipv6_payload(next_header: u8, upper_payload: &[u8]) -> Vec { + let mut packet = vec![0; 40]; packet[0] = 0x60; - packet[6] = IPV6_NEXT_HEADER_ICMPV6; - packet[40] = ICMPV6_ROUTER_ADVERTISEMENT; + packet[6] = next_header; + packet.extend_from_slice(upper_payload); + packet + } + + fn ipv6_extension_payload(next_header: u8, upper_payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(8 + upper_payload.len()); + packet.push(next_header); + packet.push(0); + packet.extend_from_slice(&[0; 6]); + packet.extend_from_slice(upper_payload); packet } @@ -1404,6 +1480,47 @@ mod tests { assert_filtered(&decision, DropReason::Ipv6RouterAdvertisement); } + #[test] + fn filters_remote_ipv6_router_advertisements_after_extension_headers() { + let mut registry = RoomRegistry::default(); + registry.join(gateway_hello()).unwrap(); + let client = registry.join(client_hello(1)).unwrap(); + let destination = MacAddr::new([0x33, 0x33, 0, 0, 0, 1]); + let frame = ethernet_with_payload( + destination, + mac(1), + ETHERTYPE_IPV6, + &ipv6_router_advertisement_after_destination_options_payload(), + ); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_filtered(&decision, DropReason::Ipv6RouterAdvertisement); + } + + #[test] + fn allows_remote_icmpv6_that_is_not_router_advertisement() { + 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 frame = ethernet_with_payload( + destination, + mac(1), + ETHERTYPE_IPV6, + &ipv6_echo_request_after_destination_options_payload(), + ); + + let decision = registry + .forward_ethernet(&room(), client.peer().peer_id(), &frame) + .unwrap(); + + assert_eq!(decision.action(), FrameAction::Forwarded); + assert_eq!(decision.targets(), &[gateway.peer().peer_id()]); + } + #[test] fn drops_malformed_frames() { let mut registry = RoomRegistry::default();