fix(ws): route plugin broadcasts through the service-layer send check (W2-7)

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 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-19 09:03:03 +02:00
co-authored by Claude Fable 5
parent 7fbfbef581
commit b329a61a7c
4 changed files with 45 additions and 20 deletions
+13
View File
@@ -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 {
+8 -1
View File
@@ -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
}
+19 -19
View File
@@ -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
}
+5
View File
@@ -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)