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
This commit is contained in:
2026-05-21 23:26:07 +02:00
parent 47f66b7d04
commit b310a33bb2
2 changed files with 126 additions and 9 deletions
+125 -8
View File
@@ -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<usize> {
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<u8> {
let mut packet = vec![0; 41];
ipv6_payload(IPV6_NEXT_HEADER_ICMPV6, &[ICMPV6_ROUTER_ADVERTISEMENT])
}
fn ipv6_router_advertisement_after_destination_options_payload() -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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();