From b329a61a7c3a8429559d55942240a6828faa7437 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:03:03 +0200 Subject: [PATCH] fix(ws): route plugin broadcasts through the service-layer send check (W2-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireChannelBroadcastAccess went through RequireChannelAccess, whose DM branch checks only participant membership — a blocked user's plugin broadcast could reach the person who blocked them — and it issued a raw GetRoleByID per broadcast, bypassing the permission cache. The gate now delegates to MessageService.CanPost (extracted over checkSendPermission), so DM blocks, channel permissions, and future posting policy apply from exactly one place; fails closed when no service is wired. First brick of the permission-path unification. MemStore.GetDMRecipient gets an honest implementation so the block path is testable. Co-Authored-By: Claude Fable 5 --- Server/service/message.go | 13 ++++++++++++ Server/store/memstore.go | 9 ++++++++- Server/ws/handlers_command.go | 38 +++++++++++++++++------------------ Server/ws/hub.go | 5 +++++ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/Server/service/message.go b/Server/service/message.go index 4a76a569..9dd0a4d8 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -677,6 +677,19 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) return ids, nil } +// CanPost reports whether userID may post into channelID, applying the same +// checks as a real message send: channel permissions via the cached checker +// for regular channels; participant membership AND block status for DMs. +// Exists so gates outside the send flow (the plugin broadcast path) share +// exactly this policy instead of hand-rolling a weaker copy. +func (s *MessageService) CanPost(userID, channelID int64) error { + ch, err := s.st.GetChannel(channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + return s.checkSendPermission(userID, channelID, ch.Type == "dm") +} + // checkSendPermission validates send permission for DM and non-DM channels. func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error { if isDM { diff --git a/Server/store/memstore.go b/Server/store/memstore.go index da73d39a..1837c54a 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -673,7 +673,14 @@ func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { return ids, nil } -func (m *MemStore) GetDMRecipient(_ int64, _ int64) (*db.User, error) { +func (m *MemStore) GetDMRecipient(channelID, userID int64) (*db.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + for uid := range m.dmParticipants[channelID] { + if uid != userID { + return m.users[uid], nil + } + } return nil, nil } diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 4a8d98f6..618b9bd9 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -11,11 +11,12 @@ package ws import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) const MsgTypeChatCommand = "chat_command" @@ -100,32 +101,31 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } // requireChannelBroadcastAccess reports whether the client may post to -// channelID, mirroring the normal message-send permission path. DM channels are -// validated by participant membership; all other channels require -// READ_MESSAGES|SEND_MESSAGES. On failure it sends an error to the client and -// returns false. Routes through the shared permissions.Checker.RequireChannelAccess -// so DM handling matches the rest of the codebase. +// channelID, by delegating to the SAME service-layer check a real message +// send runs (MessageService.CanPost: cached channel permissions; DM +// membership AND DM blocks). The previous RequireChannelAccess route skipped +// the block check in its DM branch — a blocked user's plugin broadcast could +// reach the person who blocked them — and issued a raw GetRoleByID per +// broadcast, bypassing the permission cache. On failure it sends an error to +// the client and returns false. func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool { if c.user == nil { c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated")) return false } - ch, err := h.db.GetChannel(channelID) - if err != nil || ch == nil { - c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + if h.messageSvc == nil { + // No service wired (bare test hub) — fail closed rather than allow + // an ungated broadcast. + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable")) return false } - role, err := h.db.GetRoleByID(c.user.RoleID) - if err != nil || role == nil { - c.sendMsg(buildErrorMsg(ErrCodeForbidden, "role not found")) - return false - } - if accessErr := h.permChecker.RequireChannelAccess( - c.userID, role.Permissions, role.ID, ch.Type, channelID, - permissions.ReadMessages|permissions.SendMessages, - ); accessErr != nil { + if err := h.messageSvc.CanPost(c.userID, channelID); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return false + } slog.Warn("ws plugin broadcast permission denied", - "user_id", c.userID, "channel_id", channelID, "err", accessErr) + "user_id", c.userID, "channel_id", channelID, "err", err) c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel")) return false } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 48deda38..4710547a 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -43,6 +43,10 @@ type Hub struct { lkProcess *LiveKitProcess registry *HandlerRegistry permChecker *permissions.Checker + // messageSvc gates plugin broadcasts through the same posting policy as a + // real message send (permissions, DM membership, DM blocks). Nil only in + // bare test hubs; the broadcast gate fails closed then. + messageSvc *service.MessageService pubsub *PubSub // topic-based pub/sub for O(subscribers) broadcast topicLimiter *TopicRateLimiter // per-topic throughput caps @@ -117,6 +121,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * chatDeps.MessageSvc = svc.Messages presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages + h.messageSvc = svc.Messages } registerChatHandlers(reg, chatDeps)