Files
OwnCord/Server/ws/handlers_command.go
T
J3vbandClaude Opus 4.8 7b178ff30b fix(security): harden server against verified code-review findings
Applies fixes for 20 adversarially-verified findings from a whole-codebase
security review (server side). All Go build-tag variants build, `go vet` is
clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a
pre-existing nil-harness failure unrelated to these changes).

High severity:
- auth: close TOCTOU in TOTP verify rate-limit by recording each attempt
  atomically up-front (was Check-then-Allow), restoring the per-user
  brute-force cap.
- plugin: enforce the CPU/time budget on every WASM guest call via a
  WithTimeout context (WithCloseOnContextDone interrupts runaways); the
  configured budget was previously parsed but never applied.
- api/waf: inspect request bodies for chunked (ContentLength==-1) requests
  so the SQLi/XSS/RCE body rules can no longer be bypassed.
- ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which
  fan out to every participant and could force mass disconnects.

Medium severity:
- api: run bcrypt on the unknown-user login path (no || short-circuit) to
  remove the timing-based username-enumeration oracle.
- ws: verify LiveKit webhooks via the SDK receiver so the signature is bound
  to the body hash (kills forgery/replay).
- authz: require READ_MESSAGES for reactions and for plugin-command
  broadcasts; route the latter through RequireChannelAccess.
- api: cache the client-update signature fetch and rate-limit the endpoint.
- service: propagate DeleteOtherSessions failure from ChangePassword instead
  of silently reporting success.
- api: trust the rightmost non-proxy X-Forwarded-For entry, not the
  client-controllable leftmost one.
- plugin: route auto-registered commands through the conflict-checked
  RegisterCommand; pin the DNS-validated IP for host_http dials
  (DNS-rebinding TOCTOU).
- api: mark access-controlled downloads private/no-cache + Vary: Origin.

Low severity:
- auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth
  (was returning the ciphertext as plaintext).
- api: apply the livekit-proxy path allowlist to WebSocket upgrades too.
- service: verify attachment ownership before linking (IDOR).
- admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the
  update binary hash immediately before rename+spawn (TOCTOU).
- service: require BanMembers + role hierarchy for moderation ban/unban.

chore: stop tracking the stray Server/owncord-server.exe build artifact.

Test infra: add uploader_id to the hand-rolled ws test attachment schemas and
make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:08:54 +02:00

175 lines
5.8 KiB
Go

// Phase C Step 9 — plugin slash-command dispatcher.
//
// chat_command routes a slash command from a WS client to a registered plugin.
// If no plugin owns the command, an error is returned to the sender. If the
// plugin returns a Reply, it is sent only to the invoking client (ephemeral).
// If the plugin returns a Broadcast string, it is broadcast to the channel
// only after verifying the invoking client holds SEND_MESSAGES permission.
package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/owncord/server/permissions"
)
const MsgTypeChatCommand = "chat_command"
// maxCommandArgs is the maximum number of arguments accepted in a
// chat_command payload. This prevents a malicious client from flooding
// the plugin's allocate/dispatch ABI with thousands of strings.
const maxCommandArgs = 64
// chatCommandPayload is the client-supplied payload for a chat_command message.
type chatCommandPayload struct {
ChannelID int64 `json:"channel_id"`
Command string `json:"command"` // including leading slash, e.g. "/hello"
Args []string `json:"args"`
}
// registerPluginCommandHandler registers the chat_command V1 handler.
func registerPluginCommandHandler(r *HandlerRegistry) {
r.Register(MsgTypeChatCommand, handlePluginCommand)
}
// handlePluginCommand dispatches a slash command to the owning plugin via
// hub.pluginRegistry. Returns an error to the client when:
// - the payload is malformed,
// - the command name is empty,
// - too many arguments are supplied,
// - no plugin owns the command (unknown command),
// - the plugin returns an error reply.
func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, payload json.RawMessage) {
var p chatCommandPayload
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid chat_command payload"))
return
}
cmd := strings.TrimSpace(p.Command)
if cmd == "" {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "command must not be empty"))
return
}
if len(p.Args) > maxCommandArgs {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("too many command arguments (max %d)", maxCommandArgs)))
return
}
if h.pluginRegistry == nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s (no plugins loaded)", cmd)))
return
}
result, handled := h.pluginRegistry.DispatchCommand(ctx, c.userID, p.ChannelID, cmd, p.Args)
if !handled {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, fmt.Sprintf("unknown command: %s", cmd)))
return
}
if result == nil {
// Plugin acknowledged with no output.
return
}
if result.Reply != "" {
// Ephemeral reply — sent only to the invoking client.
c.sendMsg(buildCommandReply(reqID, result.Reply))
}
if result.Broadcast != "" && p.ChannelID != 0 {
// Verify the invoking client can post to this channel before broadcasting
// the plugin result to all channel members. Mirrors the normal send path:
// non-DM channels require READ_MESSAGES|SEND_MESSAGES (so a user cannot
// post into a channel they cannot read), and DM channels are validated by
// participant membership rather than role permissions.
if !h.requireChannelBroadcastAccess(c, p.ChannelID) {
return
}
// Channel broadcast — visible to everyone in the channel.
msg := buildCommandBroadcast(p.ChannelID, c.userID, cmd, result.Broadcast)
h.BroadcastToChannel(p.ChannelID, msg)
slog.Info("plugin command broadcast", "cmd", cmd, "channel_id", p.ChannelID, "user_id", c.userID)
}
}
// 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.
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"))
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 {
slog.Warn("ws plugin broadcast permission denied",
"user_id", c.userID, "channel_id", channelID, "err", accessErr)
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel"))
return false
}
return true
}
// buildCommandReply builds an ephemeral command_reply envelope.
func buildCommandReply(reqID, text string) []byte {
type payload struct {
Text string `json:"text"`
}
type envelope struct {
Type string `json:"type"`
ReqID string `json:"req_id,omitempty"`
Payload payload `json:"payload"`
}
raw, _ := json.Marshal(envelope{
Type: "command_reply",
ReqID: reqID,
Payload: payload{Text: text},
})
return raw
}
// buildCommandBroadcast builds a plugin_broadcast envelope sent to a channel.
func buildCommandBroadcast(channelID, userID int64, cmd, text string) []byte {
type payload struct {
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
Command string `json:"command"`
Text string `json:"text"`
}
type envelope struct {
Type string `json:"type"`
Payload payload `json:"payload"`
}
raw, _ := json.Marshal(envelope{
Type: "plugin_broadcast",
Payload: payload{
ChannelID: channelID,
UserID: userID,
Command: cmd,
Text: text,
},
})
return raw
}