mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
348 lines
12 KiB
Go
348 lines
12 KiB
Go
// export_test.go exposes unexported functions and methods for use in external
|
|
// test packages (package ws_test). This file is compiled only during "go test".
|
|
package ws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"time"
|
|
|
|
"github.com/livekit/protocol/livekit"
|
|
"github.com/owncord/server/db"
|
|
)
|
|
|
|
// ─── hub sweep helpers ─────────────────────────────────────────────────────
|
|
|
|
// SweepStaleClientsForTest exposes sweepStaleClients for external tests.
|
|
func (h *Hub) SweepStaleClientsForTest() {
|
|
h.sweepStaleClients()
|
|
}
|
|
|
|
// SweepStaleVoiceStatesForTest exposes sweepStaleVoiceStates for external tests.
|
|
func (h *Hub) SweepStaleVoiceStatesForTest() {
|
|
h.sweepStaleVoiceStates()
|
|
}
|
|
|
|
// SweepRevokedSessionsForTest exposes sweepRevokedSessions for external tests.
|
|
func (h *Hub) SweepRevokedSessionsForTest() {
|
|
h.sweepRevokedSessions()
|
|
}
|
|
|
|
// SetClientLastActivityForTest overwrites a client's lastActivity timestamp.
|
|
func SetClientLastActivityForTest(c *Client, t time.Time) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.lastActivity = t
|
|
}
|
|
|
|
// ─── client getter/setter helpers ──────────────────────────────────────────
|
|
|
|
// GetLastActivityForTest exposes Client.getLastActivity for external tests.
|
|
func GetLastActivityForTest(c *Client) time.Time {
|
|
return c.getLastActivity()
|
|
}
|
|
|
|
// ClearVoiceChIDForTest exposes Client.clearVoiceChID for external tests.
|
|
func ClearVoiceChIDForTest(c *Client) int64 {
|
|
return c.clearVoiceChID()
|
|
}
|
|
|
|
// SetVoiceChIDForTest sets the voice channel ID atomically, clearing the join
|
|
// token when leaving (chID 0) — the same contract production keeps via
|
|
// setVoiceState. Test-only: production has no set-channel-without-token path.
|
|
func SetVoiceChIDForTest(c *Client, chID int64) {
|
|
c.voiceMu.Lock()
|
|
defer c.voiceMu.Unlock()
|
|
c.voiceChID = chID
|
|
if chID == 0 {
|
|
c.voiceJoinToken = ""
|
|
}
|
|
}
|
|
|
|
// SetClientVoiceChID is an alias kept for existing tests.
|
|
func SetClientVoiceChID(c *Client, channelID int64) {
|
|
SetVoiceChIDForTest(c, channelID)
|
|
}
|
|
|
|
// SetClientVoiceStateForTest sets both the voice channel and join token.
|
|
func SetClientVoiceStateForTest(c *Client, channelID int64, joinToken string) {
|
|
c.voiceMu.Lock()
|
|
defer c.voiceMu.Unlock()
|
|
c.voiceChID = channelID
|
|
c.voiceJoinToken = joinToken
|
|
}
|
|
|
|
// SetClientE2EEPubKeyForTest sets the E2EE public key on a client (no signature).
|
|
func SetClientE2EEPubKeyForTest(c *Client, key string) {
|
|
c.setE2EEPubKey(key, "")
|
|
}
|
|
|
|
// GetClientE2EEPubKeyForTest returns the E2EE public key from a client.
|
|
func GetClientE2EEPubKeyForTest(c *Client) string {
|
|
key, _ := c.getE2EEPubKey()
|
|
return key
|
|
}
|
|
|
|
// NewTestClient creates a client with a caller-supplied send channel; conn is nil.
|
|
func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client {
|
|
return &Client{
|
|
hub: hub,
|
|
ctx: context.Background(),
|
|
userID: userID,
|
|
send: send,
|
|
sendHigh: send, // unified for test observability
|
|
sendLow: send,
|
|
}
|
|
}
|
|
|
|
// NewTestClientWithChannel creates a test client subscribed to a specific channel.
|
|
func NewTestClientWithChannel(hub *Hub, userID, channelID int64, send chan []byte) *Client {
|
|
return &Client{
|
|
hub: hub,
|
|
ctx: context.Background(),
|
|
userID: userID,
|
|
channelID: channelID,
|
|
send: send,
|
|
sendHigh: send, // unified for test observability
|
|
sendLow: send,
|
|
}
|
|
}
|
|
|
|
// NewTestClientWithUser creates a test client with an authenticated user record
|
|
// set. Use this when tests need the client to pass permission checks.
|
|
func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan []byte) *Client {
|
|
return &Client{
|
|
hub: hub,
|
|
ctx: context.Background(),
|
|
userID: user.ID,
|
|
user: user,
|
|
channelID: channelID,
|
|
send: send,
|
|
sendHigh: send, // unified for test observability
|
|
sendLow: send,
|
|
}
|
|
}
|
|
|
|
// NewTestClientWithTokenHash creates a test client that carries a session token
|
|
// hash. Use this when tests need to exercise the periodic session-expiry check.
|
|
func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client {
|
|
return &Client{
|
|
hub: hub,
|
|
ctx: context.Background(),
|
|
userID: user.ID,
|
|
user: user,
|
|
tokenHash: tokenHash,
|
|
channelID: channelID,
|
|
send: send,
|
|
sendHigh: send, // unified for test observability
|
|
sendLow: send,
|
|
}
|
|
}
|
|
|
|
// TouchForTest exposes Client.touch for external tests.
|
|
func TouchForTest(c *Client) {
|
|
c.touch()
|
|
}
|
|
|
|
// RollbackVoiceJoinForTest exposes Hub.rollbackVoiceJoin for external tests.
|
|
func (h *Hub) RollbackVoiceJoinForTest(c *Client, channelID int64) {
|
|
h.rollbackVoiceJoin(context.Background(), c, channelID, true)
|
|
}
|
|
|
|
// LeaveVoiceChannelWithRetryForTest exposes leaveVoiceChannelWithRetry for external tests.
|
|
func LeaveVoiceChannelWithRetryForTest(h *Hub, userID int64, channelID int64, joinToken string) error {
|
|
return leaveVoiceChannelWithRetry(context.Background(), h, userID, channelID, joinToken)
|
|
}
|
|
|
|
// ─── livekit process/webhook helpers ───────────────────────────────────────
|
|
|
|
// GenerateConfigForTest exposes LiveKitProcess.generateConfig for external tests.
|
|
func (p *LiveKitProcess) GenerateConfigForTest() (string, error) {
|
|
return p.generateConfig()
|
|
}
|
|
|
|
// SetProcessCmdForTest sets cmd to a non-nil value to simulate "already running".
|
|
func (p *LiveKitProcess) SetProcessCmdForTest() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.cmd = &exec.Cmd{}
|
|
}
|
|
|
|
// SetProcessStoppedForTest sets stopped=true to simulate a stopped process.
|
|
func (p *LiveKitProcess) SetProcessStoppedForTest() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.stopped = true
|
|
}
|
|
|
|
// NewHubForTest creates a minimal Hub with no DB or limiter for webhook testing.
|
|
func NewHubForTest() *Hub {
|
|
return &Hub{
|
|
clients: make(map[int64]*Client),
|
|
pubsub: NewPubSub(),
|
|
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
|
}
|
|
}
|
|
|
|
// PubSubForTest exposes the hub's PubSub for external tests.
|
|
func (h *Hub) PubSubForTest() *PubSub {
|
|
return h.pubsub
|
|
}
|
|
|
|
// BuildAuthOKForTest exposes Hub.buildAuthOK for external tests.
|
|
// Defaults to replay_source="none" since most callers test the fresh-connect
|
|
// path; tests that care about the resume tier can call buildAuthOK directly.
|
|
func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte {
|
|
return h.buildAuthOK(context.Background(), user, roleName, "none")
|
|
}
|
|
|
|
// BuildReadyForTest exposes Hub.buildReady for external tests.
|
|
// Passes nil role so no channels are visible (fail-closed, BUG-094).
|
|
func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) {
|
|
return h.buildReady(context.Background(), database, userID, nil)
|
|
}
|
|
|
|
// BuildReadyWithRoleForTest exposes Hub.buildReady with a role for external tests.
|
|
func (h *Hub) BuildReadyWithRoleForTest(database *db.DB, userID int64, role *db.Role) ([]byte, error) {
|
|
return h.buildReady(context.Background(), database, userID, role)
|
|
}
|
|
|
|
// ComputeAllowedChannelsForTest exposes Hub.computeAllowedChannels for external
|
|
// tests (the REST/WS channel-visibility agreement test).
|
|
func (h *Hub) ComputeAllowedChannelsForTest(database *db.DB, user *db.User) (map[int64]bool, error) {
|
|
return h.computeAllowedChannels(context.Background(), database, user)
|
|
}
|
|
|
|
// GetCachedSettingsForTest exposes Hub.getCachedSettings for external tests.
|
|
func (h *Hub) GetCachedSettingsForTest() (string, string) {
|
|
return h.getCachedSettings(context.Background())
|
|
}
|
|
|
|
// GetClientVoiceChIDForTest exposes Client.getVoiceChID for external tests.
|
|
func GetClientVoiceChIDForTest(c *Client) int64 {
|
|
return c.getVoiceChID()
|
|
}
|
|
|
|
// GetClientVoiceJoinTokenForTest reads the join token under voiceMu.
|
|
func GetClientVoiceJoinTokenForTest(c *Client) string {
|
|
c.voiceMu.Lock()
|
|
defer c.voiceMu.Unlock()
|
|
return c.voiceJoinToken
|
|
}
|
|
|
|
// ExpireSettingsCacheForTest forces the settings cache to appear stale so that
|
|
// the next call to getCachedSettings triggers a DB refresh.
|
|
func (h *Hub) ExpireSettingsCacheForTest() {
|
|
h.settingsMu.Lock()
|
|
defer h.settingsMu.Unlock()
|
|
h.settingsLastUpdate = time.Time{} // zero time — always older than any TTL
|
|
}
|
|
|
|
// ParseChannelIDForTest exposes parseChannelID for external tests.
|
|
func ParseChannelIDForTest(payload json.RawMessage) (int64, error) {
|
|
return parseChannelID(payload)
|
|
}
|
|
|
|
// BuildJSONForTest exposes buildJSON for external tests.
|
|
func BuildJSONForTest(v any) []byte {
|
|
return buildJSON(v)
|
|
}
|
|
|
|
// ParseIdentityForTest parses a LiveKit participant identity and discards the
|
|
// join token, exercising the production parseParticipantIdentity.
|
|
func ParseIdentityForTest(identity string) (int64, error) {
|
|
userID, _, err := parseParticipantIdentity(identity)
|
|
return userID, err
|
|
}
|
|
|
|
// ParseParticipantIdentityForTest exposes parseParticipantIdentity for tests.
|
|
func ParseParticipantIdentityForTest(identity string) (int64, string, error) {
|
|
return parseParticipantIdentity(identity)
|
|
}
|
|
|
|
// ParseRoomChannelIDForTest exposes parseRoomChannelID for external tests.
|
|
func ParseRoomChannelIDForTest(roomName string) (int64, error) {
|
|
return parseRoomChannelID(roomName)
|
|
}
|
|
|
|
// WsToHTTPForTest exposes wsToHTTP for external tests.
|
|
func WsToHTTPForTest(wsURL string) string {
|
|
return wsToHTTP(wsURL)
|
|
}
|
|
|
|
// RegisterNowForTest exposes registerNow for external tests so clients are
|
|
// visible immediately (no channel round-trip through hub.Run). No channels are
|
|
// readable, matching the hub-loop registration path.
|
|
func (h *Hub) RegisterNowForTest(c *Client) {
|
|
h.registerNow(c, nil)
|
|
}
|
|
|
|
// RegisterNowWithReadableForTest exposes registerNow with an explicit
|
|
// READ_MESSAGES channel set, as the handshake paths in serve.go supply it.
|
|
func (h *Hub) RegisterNowWithReadableForTest(c *Client, readableChannelIDs map[int64]bool) {
|
|
h.registerNow(c, readableChannelIDs)
|
|
}
|
|
|
|
// ClearVoiceStateForTest exposes clearVoiceState for external tests.
|
|
func (c *Client) ClearVoiceStateForTest() {
|
|
c.clearVoiceState()
|
|
}
|
|
|
|
// QualityBitrateForTest exposes qualityBitrate for external tests.
|
|
func QualityBitrateForTest(quality string) int {
|
|
return qualityBitrate(quality)
|
|
}
|
|
|
|
// BuildDMChannelOpenForTest exposes buildDMChannelOpen for external tests.
|
|
func BuildDMChannelOpenForTest(channelID int64, recipient *db.User) []byte {
|
|
return buildDMChannelOpen(channelID, recipient)
|
|
}
|
|
|
|
// HandleWebhookParticipantLeftForTest exposes handleWebhookParticipantLeft for
|
|
// external tests so they can simulate LiveKit webhook events without HTTP.
|
|
func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, joinToken string) {
|
|
identity := fmt.Sprintf("user-%d:%s", userID, joinToken)
|
|
roomName := fmt.Sprintf("channel-%d", channelID)
|
|
event := &livekit.WebhookEvent{
|
|
Event: "participant_left",
|
|
Participant: &livekit.ParticipantInfo{
|
|
Identity: identity,
|
|
},
|
|
Room: &livekit.Room{
|
|
Name: roomName,
|
|
},
|
|
}
|
|
h.handleWebhookParticipantLeft(context.Background(), event)
|
|
}
|
|
|
|
// HandleWebhookParticipantJoinedForTest exposes handleWebhookParticipantJoined
|
|
// for external tests. identity and roomName are passed raw so a test can feed
|
|
// malformed values through the same parse path a hostile webhook would.
|
|
func (h *Hub) HandleWebhookParticipantJoinedForTest(identity, roomName string) {
|
|
event := &livekit.WebhookEvent{
|
|
Event: "participant_joined",
|
|
Participant: &livekit.ParticipantInfo{Identity: identity},
|
|
Room: &livekit.Room{Name: roomName},
|
|
}
|
|
h.handleWebhookParticipantJoined(context.Background(), event)
|
|
}
|
|
|
|
// HandleWebhookParticipantJoinedEventForTest exposes
|
|
// handleWebhookParticipantJoined with a caller-built event so tests can cover
|
|
// the nil-participant and nil-room guards.
|
|
func (h *Hub) HandleWebhookParticipantJoinedEventForTest(event *livekit.WebhookEvent) {
|
|
h.handleWebhookParticipantJoined(context.Background(), event)
|
|
}
|
|
|
|
// MustFullResyncForTest exposes mustFullResync for external tests.
|
|
func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool {
|
|
return h.mustFullResync(lastSeq)
|
|
}
|
|
|
|
// HasChannelPermForTest exposes Hub.hasChannelPerm for external tests.
|
|
func (h *Hub) HasChannelPermForTest(c *Client, channelID, perm int64) bool {
|
|
return h.hasChannelPerm(context.Background(), c, channelID, perm)
|
|
}
|