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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<usize> {
|
||||
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<usize> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user