fix(server): exclude IPv6 flow information from endpoint identity

Session identity is the address family, the IP address, the UDP source port
and — for IPv6 — the scope identifier. `SocketAddrV6` equality additionally
covers the flow label, so comparing endpoints with `==` admitted a field that
identifies no endpoint: a client whose flow label changed mid-session could be
refused its own close and strand the session, which nothing reclaims while
expiry is unimplemented. It bought nothing in exchange, since the token,
address, port and scope are what an off-path attacker would have to guess.

Compare the specified components instead. Scope still counts, and the address
is still stored verbatim, so a later reply goes back to exactly the endpoint
the request came from.

Not reachable through a default socket today: a receiver reports the flow
label only when asked to, and there is no runtime slice yet. It is reachable
through the public core, which takes caller-supplied addresses.
This commit is contained in:
Nils Andreas Svee committed 2026-08-12 10:42:11 +07:00
1 parent d269f77c87
commit e79ea94318
2 files changed
+56 -1

No files matched your search

+24 -1
View File
@@ -213,7 +213,7 @@ impl ServerCore {
fn handle_close(&mut self, peer: SocketAddr, token: u64) {
let owns_session = self
.session(token)
.is_some_and(|session| session.peer() == peer);
.is_some_and(|session| same_endpoint(session.peer(), peer));
if owns_session {
self.sessions.remove(&token);
}
@@ -243,6 +243,29 @@ impl ServerCore {
}
}
/// Whether two source endpoints identify the same session endpoint.
///
/// Identity is the address family, the IP address, the UDP source port and —
/// for IPv6 — the scope identifier. That is the whole list, so this is not
/// simply `==`: [`SocketAddrV6`] equality also covers the flow label, which
/// identifies no endpoint. It is routing metadata that a sender or a forwarding
/// path may legitimately vary within one flow, and a receiver reports it only
/// when asked to, so admitting it into identity could strand a session that its
/// own client can then never close. It grants nothing in exchange: the token,
/// address, port and scope are what an off-path attacker would have to guess.
///
/// [`SocketAddrV6`]: std::net::SocketAddrV6
fn same_endpoint(session: SocketAddr, peer: SocketAddr) -> bool {
match (session, peer) {
(SocketAddr::V6(session), SocketAddr::V6(peer)) => {
session.ip() == peer.ip()
&& session.port() == peer.port()
&& session.scope_id() == peer.scope_id()
}
(session, peer) => session == peer,
}
}
/// Applies the open parameter rules the decoder cannot express.
///
/// Duration and Interval are accepted when absent, taking the wire default
+32
View File
@@ -6,6 +6,8 @@
//! authentication — is the same silence with the session left live, so these
//! tests assert the table as well as the reply.
use std::net::{SocketAddr, SocketAddrV6};
use irtt_proto::{encode_request, RequestToEncode};
use super::support::{
@@ -124,6 +126,36 @@ fn a_close_from_any_other_endpoint_leaves_the_session_live() {
}
}
#[test]
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
// Identity is family, address, port and scope. The flow label is routing
// metadata that may vary within one flow, so letting `SocketAddrV6`
// equality decide would strand a session its own client could never close.
let ip = "2001:db8::1".parse().unwrap();
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
assert_ne!(
unlabeled, labeled,
"the two must differ only in flow information"
);
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let token = open_session(&mut core, bound, None);
assert_eq!(
core.handle_datagram(closing, &close_request(token, None))
.unwrap(),
None
);
assert_eq!(
core.session_count(),
0,
"a close from {closing} must release the session opened from {bound}"
);
}
}
#[test]
fn an_unknown_or_zero_token_closes_nothing() {
// Zero is reserved for no-test replies and is never issued to a session, so