From 79c86a65684b2a385f5e61ad2f854f2572c7469d Mon Sep 17 00:00:00 2001 From: zskhan Date: Wed, 2 Sep 2026 14:30:04 +0200 Subject: [PATCH] Add guard for stale call ringing --- .../calling/CallingRepository.test.ts | 111 ++++++++++++++++++ .../repositories/calling/CallingRepository.ts | 16 ++- .../script/repositories/event/CallingEvent.ts | 4 +- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts b/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts index 19f0028e114..c6f5ab6b247 100644 --- a/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts +++ b/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts @@ -1006,6 +1006,117 @@ describe('CallingRepository ISO', () => { return avsCall && avsCall.destroy(avsUser); }); + it('forwards a fresh incoming SETUP request to AVS', async () => { + const currentTimestamp = new Date('2026-01-20T20:00:31.000Z').getTime(); + const freshEventTimestamp = currentTimestamp - 21_000; + const selfUser = new User(createUuid(), '', translateForTest); + selfUser.isMe = true; + + const conversation = new Conversation(createUuid(), '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest); + const {repository: callingRepo} = createCallingRepositoryForTest({ + conversationState: { + findConversation: jest.fn().mockImplementation(() => conversation), + participating_user_ets: jest.fn(), + } as unknown as ConversationState, + serverTimeHandler: { + toServerTimestamp: jest.fn().mockReturnValue(currentTimestamp), + }, + }); + + const avs = await callingRepo.initAvs(selfUser, createUuid()); + avsUser = avs.wUser; + avsCall = avs.wCall; + const recvMsgSpy = jest.spyOn(avsCall, 'recvMsg').mockReturnValue(0); + recvMsgSpy.mockClear(); + + const event = { + content: {resp: false, type: CALL_MESSAGE_TYPE.SETUP, version: '3.0'}, + conversation: conversation.id, + from: createUuid(), + sender: createUuid(), + time: new Date(freshEventTimestamp).toISOString(), + type: CALL.E_CALL, + } as CallingEvent; + + await callingRepo.onCallEvent(event, ''); + + expect(recvMsgSpy).toHaveBeenCalled(); + }); + + it('ignores an incoming SETUP event that was received hours ago', async () => { + const currentTimestamp = new Date('2026-01-20T20:00:31.000Z').getTime(); + const staleEventTimestamp = currentTimestamp - 31_000; + const selfUser = new User(createUuid(), '', translateForTest); + selfUser.isMe = true; + + const conversation = new Conversation(createUuid(), '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest); + const {repository: callingRepo} = createCallingRepositoryForTest({ + conversationState: { + findConversation: jest.fn().mockImplementation(() => conversation), + participating_user_ets: jest.fn(), + } as unknown as ConversationState, + serverTimeHandler: { + toServerTimestamp: jest.fn().mockReturnValue(currentTimestamp), + }, + }); + + const avs = await callingRepo.initAvs(selfUser, createUuid()); + avsUser = avs.wUser; + avsCall = avs.wCall; + const recvMsgSpy = jest.spyOn(avsCall, 'recvMsg').mockReturnValue(0); + recvMsgSpy.mockClear(); + + const event = { + content: {resp: false, type: CALL_MESSAGE_TYPE.SETUP, version: '3.0'}, + conversation: conversation.id, + from: createUuid(), + sender: createUuid(), + time: new Date(staleEventTimestamp).toISOString(), + type: CALL.E_CALL, + } as CallingEvent; + + await callingRepo.onCallEvent(event, ''); + + expect(recvMsgSpy).not.toHaveBeenCalled(); + }); + + it('forwards a stale SETUP response to AVS', async () => { + const currentTimestamp = new Date('2026-01-20T20:00:31.000Z').getTime(); + const staleEventTimestamp = currentTimestamp - 31_000; + const selfUser = new User(createUuid(), '', translateForTest); + selfUser.isMe = true; + + const conversation = new Conversation(createUuid(), '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest); + const {repository: callingRepo} = createCallingRepositoryForTest({ + conversationState: { + findConversation: jest.fn().mockImplementation(() => conversation), + participating_user_ets: jest.fn(), + } as unknown as ConversationState, + serverTimeHandler: { + toServerTimestamp: jest.fn().mockReturnValue(currentTimestamp), + }, + }); + + const avs = await callingRepo.initAvs(selfUser, createUuid()); + avsUser = avs.wUser; + avsCall = avs.wCall; + const recvMsgSpy = jest.spyOn(avsCall, 'recvMsg').mockReturnValue(0); + recvMsgSpy.mockClear(); + + const event = { + content: {resp: true, type: CALL_MESSAGE_TYPE.SETUP, version: '3.0'}, + conversation: conversation.id, + from: createUuid(), + sender: createUuid(), + time: new Date(staleEventTimestamp).toISOString(), + type: CALL.E_CALL, + } as CallingEvent; + + await callingRepo.onCallEvent(event, ''); + + expect(recvMsgSpy).toHaveBeenCalled(); + }); + it('creates and stores a new call when an incoming call arrives', async () => { const selfUser = new User(createUuid(), '', translateForTest); selfUser.isMe = true; diff --git a/apps/webapp/src/script/repositories/calling/CallingRepository.ts b/apps/webapp/src/script/repositories/calling/CallingRepository.ts index 75e811dd36c..db601eb098f 100644 --- a/apps/webapp/src/script/repositories/calling/CallingRepository.ts +++ b/apps/webapp/src/script/repositories/calling/CallingRepository.ts @@ -84,7 +84,7 @@ import {BackgroundEffectsHandler} from 'Repositories/media/backgroundEffectsHand import type {MediaDevicesHandler} from 'Repositories/media/MediaDevicesHandler'; import type {MediaStreamHandler} from 'Repositories/media/MediaStreamHandler'; import {MediaType} from 'Repositories/media/MediaType'; -import {backgroundEffectsStore, BackgroundEffectsQuality} from 'Repositories/media/useBackgroundEffectsStore'; +import {BackgroundEffectsQuality, backgroundEffectsStore} from 'Repositories/media/useBackgroundEffectsStore'; import type {BackgroundEffectSelection, BackgroundSource} from 'Repositories/media/VideoBackgroundEffects'; import {TeamState} from 'Repositories/team/TeamState'; import {EventName} from 'Repositories/tracking/eventName'; @@ -1149,8 +1149,20 @@ export class CallingRepository { } = event; const contentStr = JSON.stringify(content); const currentTimestamp = this.serverTimeHandler.toServerTimestamp(); + const eventTimestamp = new Date(time).getTime(); const toSecond = (timestamp: number) => Math.floor(timestamp / 1000); + if ( + content.type === CALL_MESSAGE_TYPE.SETUP && + content.resp === false && + currentTimestamp - eventTimestamp > EventRepository.CONFIG.E_CALL_EVENT_LIFETIME + ) { + this.logger.warn( + `Ignoring stale incoming call SETUP: age=${currentTimestamp - eventTimestamp}ms conversation=${conversation.id}`, + ); + return; + } + const isFederated = this.core.backendFeatures.isFederated && qualified_conversation && qualified_from; const userId = isFederated ? qualified_from : {domain: '', id: from}; @@ -1164,7 +1176,7 @@ export class CallingRepository { contentStr, contentStr.length, toSecond(currentTimestamp), - toSecond(new Date(time).getTime()), + toSecond(eventTimestamp), this.serializeQualifiedId(conversation.qualifiedId), this.serializeQualifiedId(userId), conversation && isMLSConversation(conversation) ? senderClientId : clientId, diff --git a/apps/webapp/src/script/repositories/event/CallingEvent.ts b/apps/webapp/src/script/repositories/event/CallingEvent.ts index 3219ab087cf..adab98fe5cb 100644 --- a/apps/webapp/src/script/repositories/event/CallingEvent.ts +++ b/apps/webapp/src/script/repositories/event/CallingEvent.ts @@ -44,7 +44,9 @@ type CallingEventContent = { emojis: InCallEmojiType; isHandUp: boolean; } - : {type: EventType; version: string}; + : EventType extends CALL_MESSAGE_TYPE.SETUP + ? {type: EventType; version: string; resp: boolean} + : {type: EventType; version: string}; }[CALL_MESSAGE_TYPE]; export interface CallingEvent {