fix(peer): honour change hints only from the claimed peer's address
Security audit findings NET-01 (partial) and Codex #9 ("unauthenticated hints can make the victim pull arbitrary known peers"). Inbound QUIC connections are intentionally anonymous: only the responder is authenticated, so any LAN host can open a stream and send `LibraryChanged`/`CallToPlayChanged` hints naming any `claimed_peer_id`. If the claimed peer was known and the forged session or revision did not match the cached snapshot, state sync scheduled a full pinned Hello pull to that peer. Sending one small forged hint to every node on the LAN therefore made all of them pull a victim's complete snapshot at once (reflected amplification), bounded only by the 5-second per-peer coalesce window. Full mutual TLS would bind hints to a verified identity but is a larger protocol change than this application warrants. Instead the hint now carries the source IP of the anonymous connection, and `hint_requires_pull_from_snapshot` discards any hint whose source IP differs from the address at which the claimed peer was last authenticated. On a LAN a QUIC connection cannot be established from a spoofed IP, so a third host can no longer select which peer this node pulls. A genuine peer whose address changed loses only the hint fast path; mDNS rediscovery and pinned liveness reconciliation still pick it up. `PeerEndpointGeneration::for_tests` is added under cfg(test) so unit tests can build a `PeerRevisionSnapshot`. Test plan: `just test`. The new test accepts a hint from the peer's address, rejects the same hint from another IP or with no address, and keeps the revision comparison for matching sources. Manual: with two peer-cli containers, adding a game on one still triggers the other to refresh its library promptly. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
This commit is contained in:
3 files changed
+122
-10
No files matched your search
@@ -67,6 +67,7 @@ pub(super) async fn handle_peer_stream(
|
||||
let mut framed_tx = FramedWrite::new(tx, control_codec());
|
||||
log::trace!("{remote_addr:?} peer stream opened");
|
||||
|
||||
let source_ip = remote_addr.map(|addr| addr.ip());
|
||||
let first_frame = read_expected_frame(&mut framed_rx, &stream_shutdown).await;
|
||||
let mut control_permit = Some(control_permit);
|
||||
let mut _bulk_permit = None;
|
||||
@@ -87,9 +88,14 @@ pub(super) async fn handle_peer_stream(
|
||||
drop(control_permit.take());
|
||||
if let Ok(permit) = bulk_permit {
|
||||
_bulk_permit = Some(permit);
|
||||
let dispatched =
|
||||
dispatch_request(&ctx, request, framed_tx, &stream_shutdown)
|
||||
.await;
|
||||
let dispatched = dispatch_request(
|
||||
&ctx,
|
||||
request,
|
||||
source_ip,
|
||||
framed_tx,
|
||||
&stream_shutdown,
|
||||
)
|
||||
.await;
|
||||
framed_tx = dispatched.writer;
|
||||
response_reset = dispatched.response_reset;
|
||||
} else {
|
||||
@@ -99,8 +105,14 @@ pub(super) async fn handle_peer_stream(
|
||||
response_reset = true;
|
||||
}
|
||||
} else {
|
||||
let dispatched =
|
||||
dispatch_request(&ctx, request, framed_tx, &stream_shutdown).await;
|
||||
let dispatched = dispatch_request(
|
||||
&ctx,
|
||||
request,
|
||||
source_ip,
|
||||
framed_tx,
|
||||
&stream_shutdown,
|
||||
)
|
||||
.await;
|
||||
framed_tx = dispatched.writer;
|
||||
response_reset = dispatched.response_reset;
|
||||
}
|
||||
@@ -214,6 +226,7 @@ async fn read_expected_eof(
|
||||
async fn dispatch_request(
|
||||
ctx: &PeerCtx,
|
||||
request: Request,
|
||||
source_ip: Option<std::net::IpAddr>,
|
||||
framed_tx: ResponseWriter,
|
||||
stream_shutdown: &CancellationToken,
|
||||
) -> DispatchResult {
|
||||
@@ -274,11 +287,13 @@ async fn dispatch_request(
|
||||
}
|
||||
}
|
||||
Request::LibraryChanged(hint) => {
|
||||
ctx.state_sync.schedule_hint(StateDomain::Library, hint);
|
||||
ctx.state_sync
|
||||
.schedule_hint(StateDomain::Library, hint, source_ip);
|
||||
DispatchResult::close(framed_tx)
|
||||
}
|
||||
Request::CallToPlayChanged(hint) => {
|
||||
ctx.state_sync.schedule_hint(StateDomain::CallToPlay, hint);
|
||||
ctx.state_sync
|
||||
.schedule_hint(StateDomain::CallToPlay, hint, source_ip);
|
||||
DispatchResult::close(framed_tx)
|
||||
}
|
||||
Request::GetGameFileChunk {
|
||||
|
||||
Reference in new issue
Block a user