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
+1
View File
@@ -36,6 +36,7 @@ pub enum DropReason {
ControlPlaneEtherType,
DhcpServerReply,
Ipv6RouterAdvertisement,
Ipv6Fragment,
DatagramBudget,
UnknownDestination,
RateLimit,
+54
View File
@@ -740,6 +740,10 @@ fn safety_drop_reason(ingress_role: Role, frame: EthernetFrame<'_>) -> Option<Dr
return Some(DropReason::Ipv6RouterAdvertisement);
}
if ingress_role == Role::Client && is_ipv6_fragment(frame) {
return Some(DropReason::Ipv6Fragment);
}
None
}
@@ -823,10 +827,24 @@ fn is_ipv6_router_advertisement(frame: EthernetFrame<'_>) -> bool {
.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 {
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> {
let mut next_header = *ipv6.get(6)?;
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> {
let mut packet = vec![0; 40];
packet[0] = 0x60;
@@ -1551,6 +1579,32 @@ mod tests {
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]
fn filters_remote_ipv6_router_advertisements() {
let mut registry = RoomRegistry::default();