Files
OwnCord/Server/ws/event.go
T
J3vbandClaude Opus 5 17b17eb1b3 fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1)

adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)

registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES to delete messages (F4)

The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)

Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): enforce the DM block at every DM interaction sink (F5)

The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)

voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)

The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)

Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)

A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): reserve the [deleted-N] username namespace (F11, F12)

The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): strip Unicode format characters from upload filenames (F13)

The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): reserve the login attempt before the bcrypt compare (F3)

The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.

Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.

Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri

Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:

  tauri-build        2.5.6 -> 2.6.3
  tauri-plugin-fs    2.4.5 -> 2.5.1
  tauri-plugin-http  2.5.7 -> 2.5.9
  tauri-plugin-store 2.4.2 -> 2.4.4
  webpki-roots       1.0.6 -> 1.0.9

All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.

Verified with cargo check --locked --all-targets (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client

Dependabot #1258.

8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:

  UserBar.ts / members.store.ts  "online" as UserStatus -> "online"
                                 (the receiver already accepts the literal)
  media.ts                       drops `as RequestInit` on a literal that is
                                 already assignable
  LoginForm.ts                   drops `as { message: unknown }` made
                                 redundant by the `"message" in err` narrowing

All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:48:47 +02:00

327 lines
12 KiB
Go

package ws
// ClientError represents an error to send back to the requesting client.
// It implements the error interface so it can be used as Result.Error.
type ClientError struct {
Code string
Message string
}
func (e ClientError) Error() string { return e.Code + ": " + e.Message }
// Result is returned by V2 handlers. It describes the outcome of processing
// a Command: zero or more Events to emit, an optional error, and an optional
// Reply (ACK) to send back to the sender.
type Result struct {
// Events to route to other clients via EmitEvents.
Events []Event
// Error, if non-nil, is sent to the client. Use ClientError for
// user-facing errors; other error types are treated as internal.
Error error
// Reply is an optional raw JSON ACK sent only to the sender
// (e.g. chat_send_ok with the new message ID).
Reply []byte
// SetChannelID, if non-nil, updates the client's focused channel.
// Used by channel_focus to mutate client state from a V2 handler.
SetChannelID *int64
// SetE2EEPubKey, if non-nil, stores the ECDH public key on the client.
// Used by voice_e2ee_announce to persist the key for later retrieval.
SetE2EEPubKey *string
// SetE2EESignature, if non-nil, stores the identity-key signature over
// the announced ephemeral key (F3 TOFU) alongside SetE2EEPubKey, so the
// late-joiner replay path relays it. Only meaningful when SetE2EEPubKey
// is also set; nil for legacy announces without a signature.
SetE2EESignature *string
// SetVoiceJoinToken, if non-nil, caches the voice join token on the client.
// Used by voice_token_refresh when falling back to the DB for the token.
SetVoiceJoinToken *string
// JoinVoice, if true, triggers the hub's voice-join routine after the handler
// returns. voice_join's effect is a large, hub-coupled sequence (DB
// persistence, LiveKit token, existing-state fan-out, key-holder election,
// topic subscription) that also invokes the leave routine on a channel
// switch, so the applier runs handleVoiceJoin (re-parsing the envelope
// payload it already validated) rather than re-expressing it as pure events.
JoinVoice bool
// LeaveVoice, if true, triggers the hub's voice-leave routine after the
// handler returns. handleVoiceLeave stays hub-internal because disconnect and
// channel-switch cleanup call it un-throttled; only the message dispatch
// moved to V2 (which does the rate-limit before setting this flag).
LeaveVoice bool
}
// Event is the base interface for all server-to-client events.
type Event interface {
// EventType returns the outbound message type constant (e.g. MsgTypeChatMessage).
EventType() string
}
// ── Routing interfaces ──────────────────────────────────────────────────────
// EmitEvents will type-switch on these interfaces to decide how to deliver
// each Event. The check order matters: SequencedDMEvent MUST be checked
// before ChannelEvent because DM events implement both.
// ChannelEvent routes to Hub.BroadcastToChannel (sequenced, replayable).
type ChannelEvent interface {
Event
ChannelID() int64
Payload() []byte
}
// ExcludeSenderEvent routes to Hub.broadcastExclude (ephemeral, not replayed).
// Used for typing indicators in non-DM channels.
type ExcludeSenderEvent interface {
Event
ChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// SequencedDMEvent routes to Hub.sendSequencedToUsers (sequenced, replayable).
// Used for chat messages, edits, deletes, and reactions in DM channels.
type SequencedDMEvent interface {
Event
ChannelID() int64
ParticipantIDs() []int64
Payload() []byte
}
// UserTargetedEvent routes to Hub.SendToUser (direct delivery to one user).
type UserTargetedEvent interface {
Event
TargetUserID() int64
Payload() []byte
}
// BroadcastAllEvent routes to Hub.BroadcastToAll (channelID=0, all clients).
type BroadcastAllEvent interface {
Event
Payload() []byte
}
// VoiceVisibilityEvent routes to Hub.broadcastVoiceEvent: server-wide in scope,
// but delivered only to clients whose role may READ the named channel, and
// tagged with it so reconnect replay filters it the same way. MUST be checked
// before BroadcastAllEvent, which it would otherwise satisfy.
type VoiceVisibilityEvent interface {
Event
VisibleChannelID() int64
Payload() []byte
}
// VoiceChannelEvent routes to Hub.sendToVoiceChannelExcept (ephemeral,
// targets voice channel participants excluding sender).
type VoiceChannelEvent interface {
Event
VoiceChannelID() int64
ExcludeUserID() int64
Payload() []byte
}
// VoiceChannelGuardedEvent routes to Hub.sendToUserIfInVoiceChannel —
// atomic check-and-send that verifies the target is still in the expected
// voice channel before delivering the message, all under a single h.mu.RLock.
// Used by voice_e2ee_offer to prevent TOCTOU races with concurrent voice_leave.
type VoiceChannelGuardedEvent interface {
Event
VoiceChannelID() int64
TargetUserID() int64
Payload() []byte
}
// ── Concrete event structs ──────────────────────────────────────────────────
// MessageSentChannelEvent is a chat message broadcast to a non-DM channel.
type MessageSentChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageSentChannelEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentChannelEvent) Payload() []byte { return e.payload }
// MessageSentDMEvent is a chat message broadcast to a DM channel's participants.
type MessageSentDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageSentDMEvent) EventType() string { return MsgTypeChatMessage }
func (e MessageSentDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageSentDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageSentDMEvent) Payload() []byte { return e.payload }
// MessageEditedChannelEvent is a chat_edited broadcast to a non-DM channel.
type MessageEditedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageEditedChannelEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedChannelEvent) Payload() []byte { return e.payload }
// MessageEditedDMEvent is a chat_edited broadcast to DM participants.
type MessageEditedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageEditedDMEvent) EventType() string { return MsgTypeChatEdited }
func (e MessageEditedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageEditedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageEditedDMEvent) Payload() []byte { return e.payload }
// MessageDeletedChannelEvent is a chat_deleted broadcast to a non-DM channel.
type MessageDeletedChannelEvent struct {
channelID int64
payload []byte
}
func (e MessageDeletedChannelEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedChannelEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedChannelEvent) Payload() []byte { return e.payload }
// MessageDeletedDMEvent is a chat_deleted broadcast to DM participants.
type MessageDeletedDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e MessageDeletedDMEvent) EventType() string { return MsgTypeChatDeleted }
func (e MessageDeletedDMEvent) ChannelID() int64 { return e.channelID }
func (e MessageDeletedDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e MessageDeletedDMEvent) Payload() []byte { return e.payload }
// TypingChannelEvent is a typing indicator broadcast to a channel, excluding sender.
type TypingChannelEvent struct {
channelID int64
excludeUserID int64
payload []byte
}
func (e TypingChannelEvent) EventType() string { return MsgTypeTyping }
func (e TypingChannelEvent) ChannelID() int64 { return e.channelID }
func (e TypingChannelEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e TypingChannelEvent) Payload() []byte { return e.payload }
// TypingDMEvent is a typing indicator sent to DM participants, excluding sender.
// It uses UserTargetedEvent routing because DM typing excludes the sender and
// is delivered directly to each other participant.
type TypingDMEvent struct {
targetUserID int64
payload []byte
}
func (e TypingDMEvent) EventType() string { return MsgTypeTyping }
func (e TypingDMEvent) TargetUserID() int64 { return e.targetUserID }
func (e TypingDMEvent) Payload() []byte { return e.payload }
// PresenceEvent is a presence update broadcast to all connected clients.
type PresenceEvent struct {
payload []byte
}
func (e PresenceEvent) EventType() string { return MsgTypePresence }
func (e PresenceEvent) Payload() []byte { return e.payload }
// ReactionChannelEvent is a reaction update broadcast to a non-DM channel.
type ReactionChannelEvent struct {
channelID int64
payload []byte
}
func (e ReactionChannelEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionChannelEvent) ChannelID() int64 { return e.channelID }
func (e ReactionChannelEvent) Payload() []byte { return e.payload }
// ReactionDMEvent is a reaction update broadcast to DM participants.
type ReactionDMEvent struct {
channelID int64
participantIDs []int64
payload []byte
}
func (e ReactionDMEvent) EventType() string { return MsgTypeReactionUpdate }
func (e ReactionDMEvent) ChannelID() int64 { return e.channelID }
func (e ReactionDMEvent) ParticipantIDs() []int64 {
dst := make([]int64, len(e.participantIDs))
copy(dst, e.participantIDs)
return dst
}
func (e ReactionDMEvent) Payload() []byte { return e.payload }
// VoiceStateEvent is a voice state update fanned out to the clients whose role
// may READ the voice channel it describes. Satisfies VoiceVisibilityEvent.
type VoiceStateEvent struct {
voiceChannelID int64
payload []byte
}
func (e VoiceStateEvent) EventType() string { return MsgTypeVoiceState }
func (e VoiceStateEvent) VisibleChannelID() int64 { return e.voiceChannelID }
func (e VoiceStateEvent) Payload() []byte { return e.payload }
// PluginBroadcastEvent is a plugin slash-command result broadcast to a channel
// (sequenced, replayable). Emitted by the chat_command handler after the
// invoking user's post permission is verified.
type PluginBroadcastEvent struct {
channelID int64
payload []byte
}
func (e PluginBroadcastEvent) EventType() string { return "plugin_broadcast" }
func (e PluginBroadcastEvent) ChannelID() int64 { return e.channelID }
func (e PluginBroadcastEvent) Payload() []byte { return e.payload }
// VoiceE2EEAnnounceEvent relays an ECDH public key to other voice channel participants.
type VoiceE2EEAnnounceEvent struct {
voiceChannelID int64
excludeUserID int64
payload []byte
}
func (e VoiceE2EEAnnounceEvent) EventType() string { return MsgTypeVoiceE2EEAnnounceBC }
func (e VoiceE2EEAnnounceEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEAnnounceEvent) ExcludeUserID() int64 { return e.excludeUserID }
func (e VoiceE2EEAnnounceEvent) Payload() []byte { return e.payload }
// VoiceE2EEOfferGuardedEvent relays an encrypted room key to a specific user,
// using atomic check-and-send to verify the target is still in the same voice
// channel. Satisfies VoiceChannelGuardedEvent.
type VoiceE2EEOfferGuardedEvent struct {
voiceChannelID int64
targetUserID int64
payload []byte
}
func (e VoiceE2EEOfferGuardedEvent) EventType() string { return MsgTypeVoiceE2EEOfferRelay }
func (e VoiceE2EEOfferGuardedEvent) VoiceChannelID() int64 { return e.voiceChannelID }
func (e VoiceE2EEOfferGuardedEvent) TargetUserID() int64 { return e.targetUserID }
func (e VoiceE2EEOfferGuardedEvent) Payload() []byte { return e.payload }
// DMChannelOpenEvent sends a dm_channel_open notification to a specific user.
type DMChannelOpenEvent struct {
targetUserID int64
payload []byte
}
func (e DMChannelOpenEvent) EventType() string { return MsgTypeDMChannelOpen }
func (e DMChannelOpenEvent) TargetUserID() int64 { return e.targetUserID }
func (e DMChannelOpenEvent) Payload() []byte { return e.payload }