fix: security and correctness fixes from full code review - #10
Merged
Merged
Conversation
MESSAGE-INTEGRITY only covers the bytes preceding it, yet the attribute parser kept walking past it and collected XOR-PEER-ADDRESS, LIFETIME, USERNAME etc. from the unprotected tail. Anyone able to capture one authenticated request and spoof the client's source address could append attributes after MI and have them honoured while the HMAC still verified (e.g. extra peer permissions on CreatePermission, LIFETIME=0 on Refresh). Stop parsing at MESSAGE-INTEGRITY, per RFC 5389 section 15.4. FINGERPRINT is the only attribute allowed after MI and this server does not use it. Adds a regression test with attributes both before and after MI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB
…ect future nonces AllocationTable documents that `allocations` must be locked before any secondary index, because the cleanup paths hold an `allocations` shard write lock (via retain) and then remove from `by_client`. Two hot paths violated this in the other direction: - get_by_client held the `by_client` guard while taking an `allocations` guard. Copy the id out first so the guards never overlap. - create_or_get held the `by_client` entry guard while inserting into `allocations`. Insert into the primary map first, then claim the by_client slot, rolling back on a lost race. Both are ABBA inversions against a concurrent cleanup and can deadlock regardless of the lock's fairness policy. A watchdog regression test in the style of the existing register_ice_ufrags test covers them. AllocationTable::remove now reports whether it removed anything, and the Refresh lifetime=0 path only releases the rate-limiter quota slot when it did. Previously a concurrent cleanup reaping the same allocation led to two decrements for one allocation, letting the IP exceed its quota. The Send-indication single-port routing path now records relay success/attempt like the ChannelData and raw media paths do, so orphan-sender cleanup applies uniformly. validate_nonce rejects timestamps in the future: saturating_sub returned 0 for those and treated them as fresh indefinitely. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB
handler.rs already follows the rule "snapshot under the guard, drop it, then await"; engine.rs and Server::relay_client_data did not. Every relay path held a DashMap Ref across socket.send_to().await, and handle_channel_data held the sender's guard for its whole body while also re-entering the table. A guard parked inside a suspended future blocks the cleanup task's shard write lock; on a single-worker runtime that is a deadlock, elsewhere a stall. Introduce a small Delivery snapshot (id, client address, bound channel) captured under short-lived guards, and route all sends through it. Activity/relay bookkeeping re-acquires the allocation by id afterwards. The RTP and non-RTP ChannelData branches did identical work and are merged; the periodic RTP debug log is kept. Behaviour is otherwise unchanged, including the unique-path RTP delivery not skipping same-IP clients. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB
… and ChannelBind Second review round. Seven independent fixes, grouped here because they interleave in handler.rs. Forbidden peer addresses (security) - New is_forbidden_peer_addr(config, addr) extends the IP-level check with "own external IP on any port other than the relay port". Send indications already refused this; ChannelBind did not, so a client could bind a channel to external_ip:<any port> and reach other UDP services on the relay host through ChannelData. Applied to Send, ChannelBind and the ChannelData egress in the relay engine. - is_forbidden_peer_ip now judges IPv4-mapped IPv6 addresses by the IPv4 rules (::ffff:127.0.0.1 is loopback, ::ffff:169.254.169.254 is the metadata service) and refuses fe80::/10 unicast link-local. Relevant when external_ip is IPv6, since the server then binds a dual-stack [::]. Allocate (RFC 5766 §6.2) - Authenticate before anything else. The retransmission shortcut used to answer an *unauthenticated* Allocate with a signed success response whenever an allocation already existed for the source address. - Store the Allocate transaction id on the allocation. A repeat with the same id is a retransmission and gets the success response again; a new id over a live allocation gets 437 Allocation Mismatch, as coturn does. libwebrtc never re-Allocates on a socket after success (it refreshes), so this only affects stale state after a client restart. - Long-term credential verification is factored into verify_long_term_credentials and shared with validate_request_auth, removing a ~100-line duplicate. Anonymous and authenticated Allocate now share one admission/creation path. ChannelBind (RFC 5766 §11.2) - Rebinding a channel to a different peer, or a peer to a different channel, is rejected with 400 instead of silently accepted. The old behaviour left a stale channels_reverse entry, so traffic from the previous peer was still framed with a channel number the client now associated with the new peer. - Allocation::bind_channel additionally removes stale forward/reverse entries as defense-in-depth. - The permission installed by ChannelBind goes through the capped path; it previously bypassed max_permissions_per_alloc. Send indication to another TURN client of this server - Wrap in ChannelData / Data Indication exactly as the ChannelData path in the relay engine already did. A raw datagram from our relay address lands on the peer's TURN control socket and is discarded by its stack. Receive buffer - 2048 -> 65535 bytes. recv_from silently truncates larger datagrams, corrupting relayed payloads or making STUN messages unparseable with no error. Allocated once, so the size is free per packet. Tests: IPv4-mapped/link-local/own-IP forbidden checks, bind_channel consistency. 43 tests pass; clippy and fmt clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB
…e mutating Three follow-ups from the review of this PR. MESSAGE-INTEGRITY on error responses - RFC 5389 §10.2.3: a client using long-term credentials discards an error response that carries no MESSAGE-INTEGRITY, except 400, 401 and 438. build_error_response never appended one, so the 437 this PR added to Allocate and the 437/508 added to ChannelBind were invisible to the client: it retransmitted through its whole RTO schedule (~39.5s) and reported a plain timeout instead of the error. The success response the 437 replaces was signed, so this was a regression in client behaviour. - build_signed_error_response takes the key and appends MI; the unsigned build_error_response delegates to it with None and stays in use for the errors raised before the credentials are known. Every error sent after authentication now goes through the signed path. ChannelBind mutates nothing before the binding is validated - The permission implied by ChannelBind (§11.2) was installed before the channel/peer conflict and channel-cap checks ran. A client that rebound a channel to a different peer got 400 back, but the peer IP kept a permission slot; repeating with distinct IPs filled the permission table through requests the server was rejecting. §11.2 requires an error response to leave the allocation unchanged. - The guarded block now only decides the outcome; the permission insert and the bind follow once it is admissible. Send-indication internal routing no longer arms the orphan timer - touch_relay_attempt on a failed internal route started the 45s orphan-sender clock. That path runs during ICE, before a peer exists, so the first client into a call (ringing, waiting room, slow signalling) had its allocation reaped while actively sending and refreshing, and its next Refresh got 437. A successful route still records relay success; the ChannelData and raw media paths keep arming the timer on failure since they only run once media flows. Tests: MI present and verifiable on a signed 437, absent on an unsigned one, ChannelBind conflict leaves no permission behind, same-pair rebind is idempotent, targetless Send indication leaves the orphan timer unarmed. 48 tests pass; clippy and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ocations Follow-ups from the second review round. Post-Allocate requests authenticate first - The 437 for a missing allocation was still unsigned on Refresh, CreatePermission and ChannelBind, because the allocation was looked up before the request was authenticated and the lookup's failure had no key to sign with. Allocations live at most 60s here, so a client that loses a single Refresh hits exactly that: per RFC 5389 §10.2.3 it discards the unsigned 437, retransmits through its whole RTO schedule and reports a timeout ~40s later instead of reallocating. - verify_long_term_credentials needs only the message, so authentication moves ahead of the lookup. AuthResult and validate_request_auth are replaced by authenticate_for_allocation, which authenticates, resolves the allocation, checks the username against it (RFC 5766 §10.1) and hands back the id plus the key every response on that request is signed with. - The forbidden-peer 403 on CreatePermission and ChannelBind moves after authentication for the same reason. It also stops the server answering 403 to an unauthenticated source that has no allocation; that now gets 401. Allocate over a lapsed allocation - cleanup_expired runs every 2s, so a client that lets its 60s allocation expire and reconnects usually finds the dead entry still in the table. It got the new 437 and was locked out of the port until the reaper caught up (before this PR: a success response with lifetime 0, just as dead). handle_allocate now reaps an expired allocation and releases its quota slot - what cleanup_expired would have done - and proceeds to create a fresh one. Comment accuracy - The note added in f49d6ca claimed the ChannelData and raw media paths "only run once media flows". They can arm the orphan timer before pairing too; what actually keeps them safe is that STUN never reaches them - engine.rs routes ICE in its own branch, and relay_client_data only sees RTP/RTCP/DTLS. Corrected to say that. Tests: signed 437 on Refresh without an allocation, signed 403 on both forbidden-peer paths, 401 when the credentials do not match the allocation's owner, Allocate over an expired allocation starts fresh, and 437 still stands for a new transaction id over a live one. 54 tests pass; clippy and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A full read-through of
src/turned up a set of security, concurrency and RFC-conformance issues. This PR fixes all of the verified ones in four commits. No functional features are added; behaviour changes are limited to cases where the server previously accepted something RFC 5766 says it must reject (called out below).Security
demux/stun.rs). The parser used to keep collecting XOR-PEER-ADDRESS, LIFETIME, USERNAME etc. from the unprotected tail of the message, so anyone who could capture one authenticated request and spoof the client's source address could append attributes and have them honoured while the HMAC still verified. Parsing now stops at MESSAGE-INTEGRITY per RFC 5389 §15.4.turn/handler.rs). The retransmission shortcut answered an unauthenticated Allocate with a signed success response whenever an allocation already existed for the source address.external_ip:<other port>; ChannelBind did not, so a client could reach other UDP services on the relay host through ChannelData. A sharedis_forbidden_peer_addrnow guards Send, ChannelBind and the ChannelData egress.::ffff:127.0.0.1,::ffff:169.254.169.254,fe80::/10) are judged correctly. Relevant whenexternal_ipis IPv6, since the server then binds a dual-stack[::].saturating_subreturned 0 for them and treated them as fresh indefinitely.Concurrency
lookup/table.rs).get_by_clientandcreate_or_getheld aby_clientguard while lockingallocations; the cleanup paths holdallocations(viaretain) and then remove fromby_client. That is an ABBA inversion against the documented lock order and can deadlock regardless of the lock's fairness policy. Both now release one guard before taking the other. A watchdog regression test in the style of the existing one covers it.relay/engine.rs,server.rs).handler.rsalready followed this rule; the relay engine held DashMapRefs across everysend_to().await, which stalls cleanup and deadlocks a single-worker runtime. Sends now go through a smallDeliverysnapshot captured under short-lived guards. The identical RTP / non-RTP ChannelData branches were merged in the process.RFC 5766 conformance (behaviour changes)
bind_channelalso cleans stale entries as defense-in-depth.max_permissions_per_alloc.Other fixes
AllocationTable::removenow returnsbool.recv_fromsilently truncated larger datagrams.verify_long_term_credentialsshared by Allocate and the post-Allocate requests, removing a ~100-line duplicate.Follow-up review rounds
Two further review passes over this branch turned up five issues in the code it adds. Fixed in
f49d6caand74a067b.Error responses were unsigned.
build_error_responsenever appended MESSAGE-INTEGRITY, and RFC 5389 §10.2.3 has a client using long-term credentials discard an error response without it (400, 401 and 438 excepted). The new 437s and 508s were therefore invisible: the client retransmitted through its whole RTO schedule (~39.5s) and reported a timeout instead of the error. Allocations here live at most 60s, so a client that loses a single Refresh hit exactly that.build_signed_error_responsenow signs every error raised after authentication, andauthenticate_for_allocationmoves authentication ahead of the allocation lookup so the 437 for a missing allocation has a key to sign with.AuthResult/validate_request_authare replaced by it. The forbidden-peer 403 moves after authentication for the same reason; an unauthenticated source with no allocation now gets 401 rather than 403.ChannelBind mutated state before validating. The implied permission was installed before the conflict and channel-cap checks ran, so a client could fill its permission table with peer IPs through ChannelBinds the server went on to reject with 400. §11.2 requires an error response to leave the allocation unchanged. The guarded block now only decides the outcome; the permission insert and the bind follow once it is admissible.
Allocate over a lapsed allocation.
cleanup_expiredruns every 2s, so a client that lets its allocation expire and reconnects usually finds the dead entry still in the table. It got the new 437 and was locked out of the port until the reaper caught up (before this PR: a success response withlifetime = 0, just as dead).handle_allocatenow reaps the expired allocation, releases its quota slot ascleanup_expiredwould have, and creates a fresh one.The orphan-sender timer is no longer armed from Send-indication routing. Feeding it there looked uniform but reaped real allocations: that path carries ICE connectivity checks, sent before any peer exists, so the first client into a call (ringing, waiting room, slow signalling) lost its allocation 45s later while actively sending and refreshing, and its next Refresh got 437. A successful internal route still records relay success. The ChannelData and raw media paths keep arming it on a failed relay - STUN never reaches them, so by then the client is past ICE. Trade-off: an allocation that never relays successfully is no longer reapable by
cleanup_orphaned_senders, bounded only by the per-IP quota. Narrowing that check is left for a separate change.Verification
cargo fmt --check,cargo clippy --all-targets: cleancargo test: 54 passed (18 new: MI-tail regression, two lock-order watchdogs,removesemantics,bind_channelconsistency, IPv4-mapped/link-local/own-IP forbidden checks, future nonce, signed vs unsigned error responses, ChannelBind conflict leaves no permission behind, same-pair rebind is idempotent, targetless Send indication leaves the orphan timer unarmed, signed 437 on Refresh without an allocation, signed 403 on both forbidden-peer paths, 401 on a credential/allocation owner mismatch, Allocate over an expired allocation starts fresh, 437 for a new transaction id over a live one)test-webrtc/webrtc-test.htmlin two tabs against the release binary,iceTransportPolicy: 'relay'. Two complete calls. Allocate, CreatePermission, ChannelBind0x4000both ways, ICE Binding requests relayed as Data Indications, then continuous bidirectional ChannelData; the first call torn down gracefully with Refreshlifetime=0on both sides, exercising theremove()quota path. ZeroWARNand zeroERRORacross 1692 log lines - no 437, no 403, no ChannelBind conflict, no cap exceeded, no stale nonce. The only 401s are the expected first-contact challenges. Both RFC-conformance behaviour changes behave as predicted: libwebrtc never re-Allocates on a socket and never rebinds a channel, so neither the 437 nor the 400 path fires in a normal call.Not in this PR (design questions)
Noted during review, left for a separate discussion: ICE ufrag registration and relay-IP auto-grant are driven by unauthenticated Binding requests (first-come-first-served); a single inline receive loop means one blocked send stalls all processing; Binding requests from TURN clients are never answered, so a client using the same host:port as both
stun:andturn:gets no srflx candidate from it.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB