Voice channel limit is only checked when a token is issued: two people joining at once both get in, and a token fetched early ignores the limit #25

Open
opened 2026-10-10 22:15:45 -04:00 by jared · 1 comment
Owner

livekit/voice-limit-guard.py decides at token issue. should_block(limit, present_users, requester) compares the limit with the users LiveKit already has connected (livekit_present_users → ListParticipants). Two things aren't counted:

  • tokens issued but not connected yet: the 1–2 s between /sfu/get and the LiveKit connect;
  • connects after issue: lk-jwt-service tokens are valid for an hour (the dev clone uses exp: now + 3600), and nothing re-checks the limit when the token is used.

Reproduced (local stack: Synapse + LiveKit + the real guard; Voice Lounge with io.lotus.voice_limit {max_users: 1}, 0/1 shown; two users press Join at the same moment):

  • run 1: both /sfu/get → 200 and both are in the call, each seeing the other. That's 2 people in a 1-person channel.
  • run 2: the guard caught the second request (403) and Element Call showed "Can't join this call — This voice channel is full." ✔
  • one after the other (second user 15 s later): Lotus disables the second user's Join button at 1/1 ✔.

So normal use mostly works and it takes exact timing to exceed the cap. But the cap isn't a hard limit: a custom client (or a second tab) can fetch a token while the room has space and connect at any time within the hour.

Fix:

  • Enforce at connect time as well. LiveKit webhooks (participant_joined) are signed with the API key the guard already holds. On a join that takes the room over its limit (distinct Matrix users, same matrix_user() reduction), call RemoveParticipant for the newest participant. Element Call then shows its normal disconnect.
  • Alternatively, reserve a slot at token issue (a short-lived in-memory reservation per user, ~30 s, counted in should_block) and serialise the check with a per-room lock. That closes the simultaneous-join race, though not the reuse of a token fetched earlier.
  • Optionally issue shorter-lived tokens (e.g. 5 min). Element Call refetches before connecting anyway.

Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny 30e2dbcd, cinny-desktop 8f67ec7, element-call 3b0050e8.

`livekit/voice-limit-guard.py` decides at **token issue**. `should_block(limit, present_users, requester)` compares the limit with the users LiveKit *already has connected* (`livekit_present_users` → `ListParticipants`). Two things aren't counted: - **tokens issued but not connected yet:** the 1–2 s between `/sfu/get` and the LiveKit connect; - **connects after issue:** lk-jwt-service tokens are valid for an hour (the dev clone uses `exp: now + 3600`), and nothing re-checks the limit when the token is used. **Reproduced** (local stack: Synapse + LiveKit + the real guard; Voice Lounge with `io.lotus.voice_limit {max_users: 1}`, 0/1 shown; two users press **Join** at the same moment): - **run 1:** both `/sfu/get` → **200** and **both are in the call**, each seeing the other. That's 2 people in a 1-person channel. - **run 2:** the guard caught the second request (403) and Element Call showed "Can't join this call — This voice channel is full." ✔ - **one after the other** (second user 15 s later): Lotus disables the second user's **Join** button at 1/1 ✔. So normal use mostly works and it takes exact timing to exceed the cap. But the cap isn't a hard limit: a custom client (or a second tab) can fetch a token while the room has space and connect at any time within the hour. **Fix:** - Enforce at **connect time** as well. LiveKit webhooks (`participant_joined`) are signed with the API key the guard already holds. On a join that takes the room over its limit (distinct Matrix users, same `matrix_user()` reduction), call `RemoveParticipant` for the newest participant. Element Call then shows its normal disconnect. - Alternatively, **reserve a slot** at token issue (a short-lived in-memory reservation per user, ~30 s, counted in `should_block`) and serialise the check with a per-room lock. That closes the simultaneous-join race, though not the reuse of a token fetched earlier. - Optionally issue shorter-lived tokens (e.g. 5 min). Element Call refetches before connecting anyway. --- _Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny `30e2dbcd`, cinny-desktop `8f67ec7`, element-call `3b0050e8`._
jared added the infrapriority: lowbug labels 2026-10-10 22:15:45 -04:00
Author
Owner

Client side of the same limit: LotusGuild/cinny#342. The Lotus prescreen counts devices instead of people, so it is stricter than this guard and shows "Channel Full" when the guard would admit.

Client side of the same limit: LotusGuild/cinny#342. The Lotus prescreen counts devices instead of people, so it is stricter than this guard and shows "Channel Full" when the guard would admit.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/matrix#25