From e98c1d7cbc4cfc06d38d96d61a3bfa7fe40a9252 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:59:13 +0200 Subject: [PATCH] fix(ws): resolve live role in hasChannelPerm to honor mid-session demotions hasChannelPerm resolved permissions from the connect-time role snapshot (c.user.RoleID), so a user reassigned to a lower role kept the old role's voice privileges (CONNECT_VOICE and the SPEAK/VIDEO grants in the LiveKit token) until reconnect. Resolve the current role via GetRoleForUser(c.userID), matching the V2 handlers. (Security scan F5) Co-Authored-By: Claude Opus 4.8 --- Server/ws/export_test.go | 5 ++++ Server/ws/handlers.go | 12 ++++++--- Server/ws/voice_perm_stale_test.go | 43 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 Server/ws/voice_perm_stale_test.go diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index 33aefb35..18f459e0 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -228,3 +228,8 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { return h.mustFullResync(lastSeq) } + +// HasChannelPermForTest exposes Hub.hasChannelPerm for external tests. +func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool { + return h.hasChannelPerm(c, channelID, perm) +} diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 38e50c7b..92e621b0 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -193,11 +193,15 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { // hasChannelPerm reports whether the client's role has all the given permission bits. // Delegates to the unified permissions.Checker. +// +// F5: resolve the user's CURRENT role via GetRoleForUser(c.userID) rather than the +// role snapshotted onto c.user at connect time. A mid-session role reassignment +// (e.g. stripping CONNECT_VOICE) must take effect immediately for the live +// connection — including the SPEAK/VIDEO grants baked into a freshly minted +// LiveKit token — instead of persisting until the user reconnects. This mirrors +// the V2 handlers, which already resolve the live role (deps.go). func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { - if c.user == nil { - return false - } - role, err := h.db.GetRoleByID(c.user.RoleID) + role, err := h.db.GetRoleForUser(c.userID) if err != nil || role == nil { return false } diff --git a/Server/ws/voice_perm_stale_test.go b/Server/ws/voice_perm_stale_test.go new file mode 100644 index 00000000..3114b517 --- /dev/null +++ b/Server/ws/voice_perm_stale_test.go @@ -0,0 +1,43 @@ +package ws_test + +import ( + "testing" + + "github.com/owncord/server/permissions" + "github.com/owncord/server/ws" +) + +// TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot locks F5: hasChannelPerm must +// resolve the user's CURRENT role, not the role snapshotted onto the Client at +// connect time. Otherwise a user reassigned to a lower role mid-session keeps the +// old role's voice privileges (CONNECT_VOICE / the SPEAK/VIDEO grants baked into +// the LiveKit token) until they reconnect. +func TestHasChannelPerm_UsesLiveRoleNotConnectSnapshot(t *testing.T) { + hub, database := newHandlerHub(t) + + // Connect-time role: Member (id 4), which carries CONNECT_VOICE. + user := seedMemberUser(t, database, "demoted") + chID := seedTestChannel(t, database, "vc-stale") + + // The client's cached user snapshot still points at the Member role — this + // is exactly the stale state the connection holds after a role reassignment. + send := make(chan []byte, 4) + c := ws.NewTestClientWithUser(hub, user, chID, send) + + // Admin reassigns the user to a role WITHOUT CONNECT_VOICE. The live WS + // connection is not refreshed, so c.user.RoleID is now stale. + if _, err := database.Exec( + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (100, 'novoice', NULL, ?, 5, 0)`, + permissions.ReadMessages, + ); err != nil { + t.Fatalf("seed novoice role: %v", err) + } + if _, err := database.Exec(`UPDATE users SET role_id = 100 WHERE id = ?`, user.ID); err != nil { + t.Fatalf("reassign user role: %v", err) + } + + if hub.HasChannelPermForTest(c, chID, permissions.ConnectVoice) { + t.Fatal("hasChannelPerm granted CONNECT_VOICE from the stale connect-time role; it must use the live role") + } +}