mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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>
175 lines
5.8 KiB
Go
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
|
|
}
|