From 0aec716d4c180a9f934e50e8ba9137d52f024f5c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:05:57 +0200 Subject: [PATCH] fix: gate all client DM events on active S2S peer status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client's direct WS connection to remote instances (via Connections) delivered DM events independently of S2S peering. Added activePeerOrigins allowlist to ready payload — all DM event handlers now silently drop events from non-home origins without an active peer. This prevents notifications, sounds, previews, typing indicators, calls, and channel updates from instances where peering was revoked or never established. --- packages/server/src/ws/handler.ts | 11 +++++++- packages/shared/src/types.ts | 2 +- packages/web/src/hooks/useWebSocket.ts | 39 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 3ce65f83..75b5afbe 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -937,6 +937,7 @@ function buildReadyPayload(userId: string): { userActivities: Record; rejectedPeerOrigins: string[]; awaitingApprovalPeerOrigins: string[]; + activePeerOrigins: string[]; pendingApprovalCount: number; } { const db = getDb(); @@ -1482,6 +1483,14 @@ function buildReadyPayload(userId: string): { .all(); const awaitingApprovalPeerOrigins = awaitingApprovalPeers.map(p => p.origin); + // Active peer origins — client uses this allowlist to gate DM events from remote instances + const activePeers = db + .select({ origin: schema.federationPeers.origin }) + .from(schema.federationPeers) + .where(eq(schema.federationPeers.status, 'active')) + .all(); + const activePeerOrigins = activePeers.map(p => p.origin); + // Pending approval count for admin notification let pendingApprovalCount = 0; if (userRow?.isAdmin === 1) { @@ -1492,7 +1501,7 @@ function buildReadyPayload(userId: string): { pendingApprovalCount = countResult?.count ?? 0; } - return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls, userActivities, rejectedPeerOrigins, awaitingApprovalPeerOrigins, pendingApprovalCount }; + return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls, userActivities, rejectedPeerOrigins, awaitingApprovalPeerOrigins, activePeerOrigins, pendingApprovalCount }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f1d284f1..68017c64 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -390,7 +390,7 @@ export type ClientEvent = // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; pendingApprovalCount?: number } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 95d1a76c..93f13535 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -28,6 +28,14 @@ export function getAwaitingApprovalPeerOrigins(): Set { return awaitingApprovalPeerOrigins; } +// Active peer origins — allowlist for processing DM events from remote instances. +// Only DMs from peered origins (or the home instance) are processed. +const activePeerOrigins = new Set(); + +export function getActivePeerOrigins(): Set { + return activePeerOrigins; +} + // ─── Connection state ───────────────────────────────────────────────────────── interface ConnectionState { @@ -397,6 +405,14 @@ function handleEvent(origin: string, event: ServerEvent): void { } } + // Populate active peer origins (allowlist for remote DM events) + activePeerOrigins.clear(); + if (Array.isArray(event.activePeerOrigins)) { + for (const o of event.activePeerOrigins) { + activePeerOrigins.add(o); + } + } + // Admin toast for pending approval requests if (event.pendingApprovalCount && event.pendingApprovalCount > 0) { const { addToast } = useUIStore.getState(); @@ -590,6 +606,13 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── DM events (all origins) ──────────────────────────────────────────── case 'dm_message_created': { + // Gate: only process DM events from the home instance or actively peered origins. + // This prevents notifications/previews from remote instances where S2S peering + // was revoked, deleted, or never established — even if the client still has a + // direct WS connection to that instance via Connections. + if (!isHome && !activePeerOrigins.has(origin)) { + break; + } if (!isHome) { normalizeMessageAssets(event.message as any, origin); if ((event.message as any).embeds) { @@ -655,6 +678,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_message_updated': + if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome) { normalizeMessageAssets(event.message as any, origin); if ((event.message as any).embeds) { @@ -686,6 +710,7 @@ function handleEvent(origin: string, event: ServerEvent): void { const { addToast } = useUIStore.getState(); rejectedPeerOrigins.add(event.peerOrigin); awaitingApprovalPeerOrigins.delete(event.peerOrigin); + activePeerOrigins.delete(event.peerOrigin); const label = event.peerLabel || event.peerOrigin; addToast( `Cannot relay messages to ${label} — ${event.reason}`, @@ -698,10 +723,12 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'federation_peer_active': { rejectedPeerOrigins.delete(event.peerOrigin); awaitingApprovalPeerOrigins.delete(event.peerOrigin); + activePeerOrigins.add(event.peerOrigin); break; } case 'dm_message_deleted': + if (!isHome && !activePeerOrigins.has(origin)) break; removeMessage(event.messageId, event.dmChannelId); break; @@ -726,6 +753,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_embeds_resolved': { + if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome) { for (const embed of event.embeds) { if (embed.image && !embed.image.startsWith('http')) { @@ -746,6 +774,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_typing': { + if (!isHome && !activePeerOrigins.has(origin)) break; let dmTypingUsername = event.username as string; if (!isHome && dmTypingUsername && !dmTypingUsername.includes('@')) { try { dmTypingUsername = `${dmTypingUsername}@${new URL(origin).host}`; } catch {} @@ -755,6 +784,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_typing_stop': { + if (!isHome && !activePeerOrigins.has(origin)) break; clearTyping(event.dmChannelId as string, event.userId as string); break; } @@ -832,6 +862,7 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── DM call events (all origins) ────────────────────────────────────── case 'dm_call_incoming': { + if (!isHome && !activePeerOrigins.has(origin)) break; // Batch ALL call state into a single set() to prevent: // 1. Ringtone multiplication (multiple subscription triggers from separate set() calls) // 2. Stale callOrigin/federatedCallId from previous calls (always overwritten) @@ -854,6 +885,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_accepted': { + if (!isHome && !activePeerOrigins.has(origin)) break; const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState(); const wasOutgoingCall = !!outgoingCall; setIncomingCall(null); @@ -877,6 +909,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_rejected': { + if (!isHome && !activePeerOrigins.has(origin)) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -887,6 +920,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_call_ended': { + if (!isHome && !activePeerOrigins.has(origin)) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -899,6 +933,7 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── DM channel events (all origins) ──────────────────────────────────── case 'dm_channel_created': { + if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome) { for (const m of event.dmChannel.members) { normalizeUserAssets(m, origin); @@ -915,10 +950,12 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_channel_closed': + if (!isHome && !activePeerOrigins.has(origin)) break; removeDmChannel(event.dmChannelId); break; case 'dm_member_added': { + if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome) normalizeUserAssets(event.user, origin); const { addDmMember } = useSpaceStore.getState(); addDmMember(event.dmChannelId, event.user); @@ -926,12 +963,14 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_member_removed': { + if (!isHome && !activePeerOrigins.has(origin)) break; const { removeDmMember } = useSpaceStore.getState(); removeDmMember(event.dmChannelId, event.userId); break; } case 'dm_owner_updated': { + if (!isHome && !activePeerOrigins.has(origin)) break; const { updateDmOwner } = useSpaceStore.getState(); updateDmOwner(event.dmChannelId, event.newOwnerId); break;