Skip to content

fix: security and correctness fixes from full code review - #10

Merged
srperens merged 6 commits into
mainfrom
claude/code-review-pd2qo2
Sep 6, 2026
Merged

srperens merged 6 commits into
mainfrom
claude/code-review-pd2qo2

Conversation

@srperens

@srperens srperens commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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

  • Attributes after MESSAGE-INTEGRITY are now ignored (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.
  • Allocate authenticates before anything else (turn/handler.rs). The retransmission shortcut answered an unauthenticated Allocate with a signed success response whenever an allocation already existed for the source address.
  • Own external IP on non-relay ports is refused on every path. Send indications already blocked external_ip:<other port>; ChannelBind did not, so a client could reach other UDP services on the relay host through ChannelData. A shared is_forbidden_peer_addr now guards Send, ChannelBind and the ChannelData egress.
  • IPv4-mapped IPv6 and link-local (::ffff:127.0.0.1, ::ffff:169.254.169.254, fe80::/10) are judged correctly. Relevant when external_ip is IPv6, since the server then binds a dual-stack [::].
  • Nonces stamped in the future are rejected. saturating_sub returned 0 for them and treated them as fresh indefinitely.

Concurrency

  • Lock-order inversion between lookups and cleanup (lookup/table.rs). get_by_client and create_or_get held a by_client guard while locking allocations; the cleanup paths hold allocations (via retain) and then remove from by_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.
  • No allocation guard is held across socket I/O in the relay paths (relay/engine.rs, server.rs). handler.rs already followed this rule; the relay engine held DashMap Refs across every send_to().await, which stalls cleanup and deadlocks a single-worker runtime. Sends now go through a small Delivery snapshot captured under short-lived guards. The identical RTP / non-RTP ChannelData branches were merged in the process.

RFC 5766 conformance (behaviour changes)

  • 437 Allocation Mismatch for a new Allocate (different transaction id) over a live allocation. A true retransmission (same id) still gets the success response. Matches coturn; libwebrtc never re-Allocates on a socket after success, so this only affects stale state after a client restart.
  • 400 Bad Request when ChannelBind would rebind a channel to a different peer or a peer to a different channel (§11.2). Previously accepted, leaving a stale reverse mapping that framed traffic from the old peer with a channel number the client now associated with the new peer. bind_channel also cleans stale entries as defense-in-depth.
  • ChannelBind's implied permission now goes through the capped path instead of bypassing max_permissions_per_alloc.

Other fixes

  • Refresh with lifetime 0 only releases the rate-limiter quota slot if it actually removed the allocation; a concurrent cleanup could otherwise cause a double decrement and let an IP exceed its quota. AllocationTable::remove now returns bool.
  • Send indications targeting another TURN client of this server are wrapped in ChannelData / Data Indication, as the ChannelData path already did. A raw datagram from the relay address lands on the peer's TURN control socket and is discarded.
  • Receive buffer 2048 → 65535 bytes; recv_from silently truncated larger datagrams.
  • Long-term credential verification is factored into one verify_long_term_credentials shared 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 f49d6ca and 74a067b.

Error responses were unsigned. build_error_response never 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_response now signs every error raised after authentication, and authenticate_for_allocation moves authentication ahead of the allocation lookup so the 437 for a missing allocation has a key to sign with. AuthResult / validate_request_auth are 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_expired runs 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 with lifetime = 0, just as dead). handle_allocate now reaps the expired allocation, releases its quota slot as cleanup_expired would 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: clean
  • cargo test: 54 passed (18 new: MI-tail regression, two lock-order watchdogs, remove semantics, bind_channel consistency, 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)
  • Browser flow: test-webrtc/webrtc-test.html in two tabs against the release binary, iceTransportPolicy: 'relay'. Two complete calls. Allocate, CreatePermission, ChannelBind 0x4000 both ways, ICE Binding requests relayed as Data Indications, then continuous bidirectional ChannelData; the first call torn down gracefully with Refresh lifetime=0 on both sides, exercising the remove() quota path. Zero WARN and zero ERROR across 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: and turn: gets no srflx candidate from it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jn5PhyfAEWPGDjLpu5rqeB

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
@srperens srperens changed the title fix(stun): ignore attributes after MESSAGE-INTEGRITY fix: security and correctness fixes from full code review Sep 4, 2026
srperens and others added 2 commits September 5, 2026 16:05
…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>
@srperens
srperens merged commit d23a115 into main Sep 6, 2026
3 checks passed
@srperens
srperens deleted the claude/code-review-pd2qo2 branch September 14, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants