fix(gateway): filter remote frames before LAN injection

The relay already filters unsafe remote-client traffic, but the gateway is the
last process before the physical LAN. Treating a relayed Ethernet frame as safe
just because it came from the relay leaves the LAN boundary dependent on one
upstream check.

Add a gateway-local remote-to-LAN safety decision before AF_PACKET writes. The
gateway now skips and logs relayed frames with invalid source MACs, L2 control
traffic, remote VLAN tags, DHCP-server replies, IPv6 Router Advertisements,
IPv6 fragments, or jumbo payloads. The public receive helper also loops past
filtered frames so callers only receive frames that can be injected.

Document the final gateway boundary check in the README and extend the gateway
relay integration test so an unsafe relayed frame is filtered before the valid
frame is delivered.

Test Plan:
- cargo test -p lanparty-gateway
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo fmt --check
- git diff --check

Refs: PLAN.md remote-to-LAN safety filters
This commit is contained in:
2026-05-22 05:22:17 +02:00
parent a3ff75b29f
commit 4d100ce800
2 changed files with 99 additions and 22 deletions
+5 -1
View File
@@ -181,7 +181,11 @@ overlay payload-length ceiling before deciding whether they fit the tunnel. It
never fragments Ethernet frames; LAN frames with invalid source MACs, L2
control-plane traffic, jumbo frames, or encoded datagrams exceeding the
negotiated QUIC budget are counted, dropped, and logged locally instead of
stopping the bridge or consuming relay bandwidth.
stopping the bridge or consuming relay bandwidth. Remote frames received from
the relay are safety-checked again before LAN injection, so invalid-source,
L2 control-plane, remote VLAN, DHCP-server, IPv6 Router Advertisement, IPv6
fragment, and jumbo frames cannot cross the gateway's final physical-LAN
boundary even if they reached the gateway over QUIC.
`--relay` accepts a DNS name or socket address; bare hosts default to UDP/443.
The gateway rejects Linux interfaces that sysfs identifies as Wi-Fi, and rejects
wired interfaces whose sysfs carrier state reports no link; managed wireless
+80 -7
View File
@@ -33,7 +33,7 @@ use lanparty_obs::{DropReason, TunnelStats};
use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram,
gateway_lan_safety_drop_reason, validate_datagram_budget,
gateway_lan_safety_drop_reason, remote_client_safety_drop_reason, validate_datagram_budget,
};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
@@ -213,6 +213,19 @@ pub struct ReceivedEthernetFrame {
payload: Bytes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FilteredRelayEthernetFrame {
source_peer_id: u32,
payload: Bytes,
drop_reason: DropReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GatewayReceiveOutcome {
Accepted(ReceivedEthernetFrame),
Filtered(FilteredRelayEthernetFrame),
}
impl ReceivedEthernetFrame {
#[must_use]
pub const fn source_peer_id(&self) -> u32 {
@@ -359,8 +372,9 @@ impl GatewayConnection {
)
);
}
relay_frame = recv_gateway_ethernet(&connection, &welcome, &stats) => {
let relay_frame = relay_frame?;
relay_frame = recv_gateway_ethernet_outcome(&connection, &welcome, &stats) => {
match relay_frame? {
GatewayReceiveOutcome::Accepted(relay_frame) => {
cam_refresh
.observe_remote_frame(relay_frame.source_peer_id(), relay_frame.payload())?;
write_lan_ethernet(&packet_socket, relay_frame.payload()).await?;
@@ -376,6 +390,21 @@ impl GatewayConnection {
)
);
}
GatewayReceiveOutcome::Filtered(relay_frame) => {
println!(
"{}",
gateway_frame_log_line(
packet_socket.get_ref().interface(),
FrameDirection::RemoteToLan,
Some(relay_frame.source_peer_id),
&relay_frame.payload,
FrameAction::Filtered,
Some(relay_frame.drop_reason),
)
);
}
}
}
_ = cam_refresh_tick.tick() => {
for frame in cam_refresh.refresh_frames() {
write_lan_ethernet(&packet_socket, &frame).await?;
@@ -465,6 +494,19 @@ async fn recv_gateway_ethernet(
welcome: &ServerWelcome,
stats: &GatewayTunnelStats,
) -> Result<ReceivedEthernetFrame> {
loop {
match recv_gateway_ethernet_outcome(connection, welcome, stats).await? {
GatewayReceiveOutcome::Accepted(frame) => return Ok(frame),
GatewayReceiveOutcome::Filtered(_) => {}
}
}
}
async fn recv_gateway_ethernet_outcome(
connection: &quinn::Connection,
welcome: &ServerWelcome,
stats: &GatewayTunnelStats,
) -> Result<GatewayReceiveOutcome> {
loop {
let datagram = connection.read_datagram().await?;
stats.record_datagram_rx();
@@ -489,11 +531,30 @@ async fn recv_gateway_ethernet(
};
stats.record_ethernet_rx(ethernet_frame);
return Ok(ReceivedEthernetFrame {
if let Some(drop_reason) = remote_to_lan_safety_drop_reason(ethernet_frame) {
stats.record_dropped_frame();
return Ok(GatewayReceiveOutcome::Filtered(
FilteredRelayEthernetFrame {
source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()),
});
drop_reason,
},
));
}
return Ok(GatewayReceiveOutcome::Accepted(ReceivedEthernetFrame {
source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()),
}));
}
}
fn remote_to_lan_safety_drop_reason(frame: EthernetFrame<'_>) -> Option<DropReason> {
if !frame.source().is_valid_unicast() {
return Some(DropReason::InvalidSourceMac);
}
remote_client_safety_drop_reason(frame).map(DropReason::from)
}
async fn send_gateway_stats(connection: &quinn::Connection, stats: TunnelStats) -> Result<()> {
@@ -987,6 +1048,18 @@ mod tests {
assert_eq!(header.peer_id(), 1);
assert_eq!(packet.payload(), ethernet_frame(b"to relay").as_slice());
let filtered_response = encode_datagram(
FrameType::Ethernet,
7,
99,
0,
&control_plane_ethernet_frame(),
)
.unwrap();
connection
.send_datagram(Bytes::from(filtered_response))
.unwrap();
let response = encode_datagram(
FrameType::Ethernet,
7,
@@ -1003,7 +1076,7 @@ mod tests {
let ControlMessage::Stats(stats) = stats_message else {
panic!("expected gateway stats event");
};
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1));
assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 5, 1));
stats_received_tx.send(()).unwrap();
let mut disconnect_recv = connection.accept_uni().await.unwrap();
@@ -1076,7 +1149,7 @@ mod tests {
.is_err()
);
let stats = gateway.stats_snapshot();
assert_eq!(stats, TunnelStats::new(1, 1, 1, 1, 4, 1));
assert_eq!(stats, TunnelStats::new(1, 2, 1, 2, 5, 1));
gateway.send_stats_snapshot().await.unwrap();
tokio::time::timeout(Duration::from_secs(5), stats_received_rx)