A frame header declares its own length, and until a connection authorizes nothing
bounded that length but the transport's ceiling: 1 GiB on TCP and on WebRTC, 64 MiB
on WebSocket. The decoder here already refuses to preallocate on a declared length,
so on TCP an attacker spends a byte sent per byte held; tungstenite does not, and
reserves a frame's whole declared payload the moment it passes max_frame_size, so a
fourteen-byte header buys 16 MiB. That is CVE-2021-32675's shape, and Redis answered
it the same way: a bound that holds only until the peer has authenticated.
`Stream::set_max_packet_length` carries such a bound to whichever transport is under
it. TCP has the knob already. WebRTC gains one: its reassembly ceiling moves from a
constant to a per-stream atomic, shared by every clone because the receive path runs
off one, and clamped to that constant so the send path's check still describes what a
peer will accept. WebSocket reaches tungstenite's own config, which needs a fork -
upstream exposes get_config and no set_config, at v0.26.2 and still at 0.30.0 - and
moves both bounds: max_frame_size is what stops a header from buying an allocation,
max_message_size is what bounds reassembly. Every transport clamps to its own
ceiling, so usize::MAX restores exactly what was there and no caller can raise one
above what it always had.
Nothing here calls it, so on its own this changes no behaviour.
Tests cover both new paths: the codec takes a header at the cap and dies on the one
a byte above it, before any payload is buffered; and a WebRTC loopback pair whose
receiver has lowered its bound refuses an oversize message, while the sender, whose
own bound is untouched, still puts it on the wire the way an unauthenticated peer
would.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
A peer that is killed, logged out or rebooted sends no reset over UDP, so a
session only learns of it by timing out. Nothing here acts on that; these are
the two observations a caller needs to.
`rx_progress` counts arriving fragments. A message reaches the caller only once
it is whole, so a large one - a clipboard image is the case that occurs - leaves
the receive loop with nothing to observe for as long as the transfer takes, and
a peer sending steadily looks exactly like one that died. A counter, not a
timestamp: `Instant::now()` costs an order of magnitude more per read, and
callers only compare successive samples.
`webrtc_disconnected` exposes the ICE state webrtc-rs already raises ~5s after
it stops hearing from the peer. It is deliberately not a fourth
`WebRTCConnectionState`: that enum gates send and receive readiness and session
cache admissibility, and a transient state must change neither.
TCP and WebSocket answer for neither, on purpose: this change leaves their
existing close, error and timeout behaviour exactly as it was.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aokqJuhjvB3kijXtAg5Ns
The label a session is reported under names the transport that won the race, not the family
ICE ended up nominating, so a WebRTC session over IPv6 read the same as one over IPv4. Take
it from the remote side of the selected pair - the address the peer is actually reached at,
which the rendezvous-observed address the session is otherwise identified by cannot report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UzcMTdYTEv2QbMHcTSUy3
A pc's UDP sockets register with the reactor, and its ICE/DTLS/SCTP
pumps spawn on the runtime, that is current while `new` builds it.
For a rustdesk controller that is io_loop's own
`#[tokio::main(flavor = "current_thread")]` runtime, dropped the
moment io_loop returns — so the pc outlived the only runtime able to
drive its I/O, and the session-end close, merely spawned there, was
never polled once. No DTLS close_notify left, and the peer waited out
ICE decay (~25-30s in its log) where TCP delivers a FIN at once.
Driving the close from somewhere else does not help: the sockets and
pumps are already gone, so close() returns having swallowed every
transport error without reaching the wire. Home the pc where it can
outlive its caller instead. `new` runs its body on WEBRTC_RT, a lazy
process-lifetime runtime, so every socket and background task belongs
to it; `close_detached_with` spawns each close there as its own task —
never cancelled, since a close cancelled after RTCPeerConnection::close
latches `is_closed` is unretryable and strands the pc in SESSIONS, and
never serialized, since close() has no deadline and one stalled pc
would otherwise block every later session's teardown. Data-plane
futures are still polled from caller runtimes; only the owning driver
has to stay alive.
A cancelled `new` needs the same care: rt.spawn keeps running when its
JoinHandle is dropped, so the setup task would finish, cache the
session, and hand its result to nobody — and an unanswered offerer
never reaches a terminal ICE state, so it sat in SESSIONS forever.
NewStreamHandoff closes an abandoned freshly-built stream, and hands a
cache hit back disarmed: that one is shared with a live caller whose
connection must survive this one's cancellation.
Tests cover the production shapes: a session-end close from a thread
with no runtime, an EOF-then-Drop double close, a close queued as the
creating runtime is destroyed, and the cancelled-new handoff both ways.
Two suite leaks surfaced on the way — streams dropped without close,
polluting SESSIONS for every later test — and are fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HV43uh1ztv6Wm5qi3Y1ne
A WebRTC peer connection outlives its handle - the session cache holds a
clone, and the handler that evicts it only fires on a terminal ICE state
- so it has to be closed explicitly. Making that a per-exit-path
obligation meant every return, break and `?` had to remember it, and the
long-lived side never did: server::connection ends its ~15 exits by
dropping the stream, and the transport race drops the losing result
outright. Nothing warned; a missed close leaks a pc, its ICE agent and
its sockets silently, and only under WebRTC.
Stream is not Clone, so dropping it is the end of the transport and
there is no second owner to surprise - drop-means-close is simply what
the type already meant. The explicit close_webrtc() calls stay valid
(they close sooner than scope end), but they are an optimization now
rather than the thing correctness rests on.
Also delete get_webrtc_stream(): it had no callers and was the one API
handing out an owned clone that outlives its Stream, i.e. the only way
to defeat this.
Regression test included; it fails with the drop body emptied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
- send_bytes computed one deadline before acquiring the send gate and
reused it for the write, so time queued behind another clone's message
was charged to this message. A caller that only just lost the gate
race got a few milliseconds to write in and then tore the whole peer
connection down for missing them - the narrower the miss, the more
certain the teardown. The write gets its own budget; the gate wait
keeps the one it had, and still never closes (it never held the
permit).
- Stream::close_webrtc awaited WebRTCStream::close, which is exactly the
form close_detached exists to avoid: most callers sit in a select! arm
or a future the UI can abandon, and a cancelled close is unretryable
(is_closed is latched before the first await, so later attempts
early-return and the handler that evicts the session never runs). It
is now a plain fn calling close_detached - with no await point there
is nothing to cancel. close_detached is public and carries the
runtime-teardown guard the parent repo had written separately for its
Drop path, so that copy goes away.
- Log retention goes back to 31 files. Raising it to 31*8 defended the
flood case badly (a file count cannot outrun a flood; only the rate
limits at the log sites can) while silently multiplying steady-state
retention and disk for every ordinary install, on every platform.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
`next()` read every non-FRAG_END header as "more fragments", so a peer whose
framing had diverged was only caught by the MAX_FRAME_LENGTH cap — and a
FRAG_MORE carrying no payload was never caught at all: it adds nothing to the
accumulator, so the cap never trips and the loop spins for as long as the peer
keeps writing, with no error and no teardown. Decide the header's meaning in one
match, so a future header kind cannot be handled in one place and missed in the
other. Neither case is reachable from send_bytes_inner, which emits FRAG_MORE
only for a full MAX_FRAGMENT_PAYLOAD chunk.
Release the accumulator on the error paths rather than truncating it: at the cap
that is ~1 GiB still referenced through the SESSIONS clone.
Doc corrections, all of them overclaims in the previous pass:
- the cancel-safety entry held only for the successful read path.
read_data_channel does await after dequeuing on its ErrShortBuffer and DCEP
branches, and next() awaits pc.close() on its error paths — where
RTCPeerConnection::close latches is_closed before its first await, so a
cancelled close silently turns every later close into a no-op and leaves the
pc in SESSIONS.
- recv_state: cancellation drops the guard mid-message, so it is the
single-reader assumption, not the mutex, that ultimately keeps two readers
from splicing into one accumulator.
- is_relayed: stream.rs promised None before pair selection while webrtc.rs
documented Some(true) under Relay policy; align both.
- get_local_endpoint: examples/webrtc.rs calls it too, not only the tests.
- PunchHole.reserved 11: named the wrong writer — PunchHole is written by the
rendezvous server, not by peers. Reserve the name as well as the tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 1-byte-header fragmentation past the 64KB SCTP cap; empty-message and clean-EOF handling
- is_relayed() via selected candidate-pair stats for the direct/relayed flag
- IdPk.dtls_fingerprint + rendezvous webrtc SDP/IceCandidate proto fields
- fix pc leaks: Weak capture breaks the state-handler Arc self-cycle; close pc on new() error paths
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>