From 756ba5f0946872627b75457b05442ec78d5b2370 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 23:30:52 +0200 Subject: [PATCH] fix(relay): filter remote DHCPv6 server replies The MVP safety policy says remote clients must not inject DHCP server replies onto the LAN. The relay enforced that for IPv4 DHCP, but DHCPv6 uses UDP 547 -> 546 over IPv6 and was not covered by the existing check. Split the DHCP server-reply predicate into IPv4 and IPv6 paths. The IPv6 path reuses the extension-header walker so server replies hidden behind ordinary IPv6 extension headers are still treated as unsafe. Keep client DHCPv6 requests allowed, and continue allowing LAN-origin DHCPv6 replies to flow back to remote clients. 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 | 5 +- crates/lanparty-relay/src/lib.rs | 121 +++++++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 454e0a3..d2b065b 100644 --- a/README.md +++ b/README.md @@ -102,8 +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, DHCP-server, - and IPv6-RA frames, including RAs behind ordinary IPv6 extension headers +- L2 safety filters for invalid-source, jumbo, switch-control, 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-relay/src/lib.rs b/crates/lanparty-relay/src/lib.rs index 4e5ac80..bf99488 100644 --- a/crates/lanparty-relay/src/lib.rs +++ b/crates/lanparty-relay/src/lib.rs @@ -32,7 +32,7 @@ const ETHERTYPE_EAPOL: u16 = 0x888e; const ETHERTYPE_SLOW_PROTOCOLS: u16 = 0x8809; const ETHERTYPE_LLDP: u16 = 0x88cc; const ETHERTYPE_IPV6: u16 = 0x86dd; -const IPV4_PROTOCOL_UDP: u8 = 17; +const IP_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; @@ -40,8 +40,10 @@ 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; +const DHCPV4_SERVER_PORT: u16 = 67; +const DHCPV4_CLIENT_PORT: u16 = 68; +const DHCPV6_CLIENT_PORT: u16 = 546; +const DHCPV6_SERVER_PORT: u16 = 547; const ICMPV6_ROUTER_ADVERTISEMENT: u8 = 134; #[derive(Debug, Clone, PartialEq, Eq)] @@ -755,6 +757,10 @@ fn is_link_local_control_destination(mac: MacAddr) -> bool { } fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { + is_ipv4_dhcp_server_reply(frame) || is_ipv6_dhcp_server_reply(frame) +} + +fn is_ipv4_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { let bytes = frame.bytes(); if frame.ethertype_or_len() != ETHERTYPE_IPV4 || bytes.len() < ETHERNET_HEADER_LEN + 20 { return false; @@ -763,10 +769,7 @@ fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { let ipv4 = &bytes[ETHERNET_HEADER_LEN..]; let version = ipv4[0] >> 4; let header_len = usize::from(ipv4[0] & 0x0f) * 4; - if version != 4 - || header_len < 20 - || ipv4.len() < header_len + 8 - || ipv4[9] != IPV4_PROTOCOL_UDP + if version != 4 || header_len < 20 || ipv4.len() < header_len + 8 || ipv4[9] != IP_PROTOCOL_UDP { return false; } @@ -775,7 +778,30 @@ fn is_dhcp_server_reply(frame: EthernetFrame<'_>) -> bool { let source_port = u16::from_be_bytes([udp[0], udp[1]]); let destination_port = u16::from_be_bytes([udp[2], udp[3]]); - source_port == DHCP_SERVER_PORT && destination_port == DHCP_CLIENT_PORT + source_port == DHCPV4_SERVER_PORT && destination_port == DHCPV4_CLIENT_PORT +} + +fn is_ipv6_dhcp_server_reply(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..]; + if !is_ipv6_packet(ipv6) { + return false; + } + + let Some(udp_offset) = ipv6_upper_layer_payload_offset(ipv6, IP_PROTOCOL_UDP) else { + return false; + }; + let Some(udp) = ipv6.get(udp_offset..udp_offset.saturating_add(4)) else { + return false; + }; + let source_port = u16::from_be_bytes([udp[0], udp[1]]); + let destination_port = u16::from_be_bytes([udp[2], udp[3]]); + + source_port == DHCPV6_SERVER_PORT && destination_port == DHCPV6_CLIENT_PORT } fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { @@ -785,12 +811,11 @@ fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { } let ipv6 = &bytes[ETHERNET_HEADER_LEN..]; - let version = ipv6[0] >> 4; - if version != 6 { + if !is_ipv6_packet(ipv6) { return false; } - let Some(icmpv6_offset) = ipv6_icmpv6_payload_offset(ipv6) else { + let Some(icmpv6_offset) = ipv6_upper_layer_payload_offset(ipv6, IPV6_NEXT_HEADER_ICMPV6) else { return false; }; @@ -798,13 +823,17 @@ fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool { .is_some_and(|message_type| *message_type == ICMPV6_ROUTER_ADVERTISEMENT) } -fn ipv6_icmpv6_payload_offset(ipv6: &[u8]) -> Option { +fn is_ipv6_packet(ipv6: &[u8]) -> bool { + ipv6.first().is_some_and(|first| first >> 4 == 6) +} + +fn ipv6_upper_layer_payload_offset(ipv6: &[u8], expected_next_header: u8) -> Option { let mut next_header = *ipv6.get(6)?; let mut offset = 40; loop { match next_header { - IPV6_NEXT_HEADER_ICMPV6 => return Some(offset), + next_header if next_header == expected_next_header => return Some(offset), IPV6_NEXT_HEADER_NO_NEXT => return None, IPV6_NEXT_HEADER_HOP_BY_HOP | IPV6_NEXT_HEADER_ROUTING @@ -897,12 +926,33 @@ mod tests { fn ipv4_udp_payload(source_port: u16, destination_port: u16) -> Vec { let mut packet = vec![0; 28]; packet[0] = 0x45; - packet[9] = IPV4_PROTOCOL_UDP; + packet[9] = IP_PROTOCOL_UDP; packet[20..22].copy_from_slice(&source_port.to_be_bytes()); packet[22..24].copy_from_slice(&destination_port.to_be_bytes()); packet } + fn udp_payload(source_port: u16, destination_port: u16) -> Vec { + let mut packet = vec![0; 8]; + packet[0..2].copy_from_slice(&source_port.to_be_bytes()); + packet[2..4].copy_from_slice(&destination_port.to_be_bytes()); + packet + } + + fn ipv6_udp_payload(source_port: u16, destination_port: u16) -> Vec { + ipv6_payload(IP_PROTOCOL_UDP, &udp_payload(source_port, destination_port)) + } + + fn ipv6_udp_after_destination_options_payload( + source_port: u16, + destination_port: u16, + ) -> Vec { + ipv6_payload( + IPV6_NEXT_HEADER_DESTINATION_OPTIONS, + &ipv6_extension_payload(IP_PROTOCOL_UDP, &udp_payload(source_port, destination_port)), + ) + } + fn ipv6_router_advertisement_payload() -> Vec { ipv6_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT]) } @@ -1442,7 +1492,7 @@ mod tests { let mut registry = RoomRegistry::default(); let gateway = registry.join(gateway_hello()).unwrap(); let client = registry.join(client_hello(1)).unwrap(); - let payload = ipv4_udp_payload(DHCP_SERVER_PORT, DHCP_CLIENT_PORT); + let payload = ipv4_udp_payload(DHCPV4_SERVER_PORT, DHCPV4_CLIENT_PORT); let client_frame = ethernet_with_payload(MacAddr::BROADCAST, mac(1), ETHERTYPE_IPV4, &payload); let gateway_frame = @@ -1460,6 +1510,47 @@ mod tests { assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); } + #[test] + fn filters_remote_dhcpv6_server_replies_but_allows_lan_replies() { + 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, 1, 0, 2]); + let payload = + ipv6_udp_after_destination_options_payload(DHCPV6_SERVER_PORT, DHCPV6_CLIENT_PORT); + 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::DhcpServerReply); + assert_eq!(gateway_decision.action(), FrameAction::Forwarded); + assert_eq!(gateway_decision.targets(), &[client.peer().peer_id()]); + } + + #[test] + fn allows_remote_dhcpv6_client_requests() { + 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, 1, 0, 2]); + let payload = ipv6_udp_payload(DHCPV6_CLIENT_PORT, DHCPV6_SERVER_PORT); + let frame = ethernet_with_payload(destination, mac(1), ETHERTYPE_IPV6, &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 filters_remote_ipv6_router_advertisements() { let mut registry = RoomRegistry::default();