Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f884752
feat: voice to video escalation
pierre-lehnen-rc Jun 16, 2026
c6f2440
feat: Voice to video escalation UI #40869
Jun 9, 2026
3d26aab
new pexip setting: extra params for escalated calls
pierre-lehnen-rc Jun 18, 2026
c8d9c04
feat: identify remote escalation
pierre-lehnen-rc Jun 19, 2026
c74fe19
feat: find DM for escalated conferences
pierre-lehnen-rc Jun 22, 2026
26a879e
feat: identify proper room to use as parent when creating an escalate…
pierre-lehnen-rc Jun 22, 2026
fe59300
feat: send videoconf message for escalated calls
pierre-lehnen-rc Jun 22, 2026
3c15dec
feat: skip "call ended" sound effect on escalated calls
pierre-lehnen-rc Jun 24, 2026
c3f8dab
feat: identify escalated calls on pexip policy server and skip PIN re…
pierre-lehnen-rc Jun 24, 2026
35d859d
refactor(ui-voip): memoize MediaCallRoomSection content
Jun 22, 2026
5a8c2a0
refactor: memoize callbacks in useVoiceToVideoEscalation
Jun 24, 2026
5d02670
refactor: add error toast when escalation fails
Jun 24, 2026
22118c7
feat: integrate video escalation to popout window
Jun 24, 2026
2dc9c7d
chore: disable transfer / hold / screen-share on escalated calls
pierre-lehnen-rc Jun 30, 2026
8400247
chore: Escalation modal text change and german translation
gabriellsh Jul 2, 2026
e68037f
test: regenerate escalation snapshots after develop rebase
ggazzo Jul 3, 2026
0c4379c
log escalation errors
pierre-lehnen-rc Jul 6, 2026
a761ff2
auto hangup voice calls when users join remotely escalated conference…
pierre-lehnen-rc Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/meteor/ee/server/settings/voip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ export function addSettings(): Promise<void> {
i18nDescription: 'VoIP_TeamCollab_ExternalCallHistory_Timeout_Description',
});
});

await this.section('VoIP_TeamCollab_AdvancedFeatures', async function () {
const enableQuery = { _id: 'Pexip_Integration_Enabled', value: true };

await this.add('VoIP_TeamCollab_Video_Escalation_Enabled', false, {
type: 'boolean',
public: true,
invalidValue: false,
enableQuery,
i18nDescription: 'VoIP_TeamCollab_Video_Escalation_Enabled_Description',
});
});
},
);
});
Expand Down
70 changes: 70 additions & 0 deletions apps/meteor/server/api/v1/media-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,76 @@ declare module '@rocket.chat/rest-typings' {
interface Endpoints extends MediaCallsAnswerEndpoints {}
}

type MediaCallsEscalate = {
callId: string;
};

const MediaCallsEscalateSchema: JSONSchemaType<MediaCallsEscalate> = {
type: 'object',
properties: {
callId: {
type: 'string',
},
},
required: ['callId'],
additionalProperties: false,
};

export const isMediaCallsEscalateProps = ajv.compile<MediaCallsEscalate>(MediaCallsEscalateSchema);

const mediaCallsEscalateEndpoints = API.v1.post(
'media-calls.escalate',
{
response: {
200: ajv.compile<{
providerName: string;
url: string;
}>({
additionalProperties: false,
type: 'object',
properties: {
providerName: {
type: 'string',
description: 'The name of the conference provider.',
},
url: {
type: 'string',
description: 'The url of the conference.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['providerName', 'url', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
body: isMediaCallsEscalateProps,
authRequired: true,
},
async function action() {
const { callId } = this.bodyParams;

const url = await MediaCall.escalateCall(this.userId, { callId });

return API.v1.success({
providerName: 'core.pexip',
url,
});
},
);

type MediaCallsEscalateEndpoints = ExtractRoutesFromAPI<typeof mediaCallsEscalateEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsEscalateEndpoints {}
}

type MediaCallsStateSignalsParams = {
contractId: string;
};
Expand Down
242 changes: 228 additions & 14 deletions apps/meteor/server/services/media-call/service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization, VideoConf } from '@rocket.chat/core-services';
import type {
IMediaCall,
IUser,
IRoom,
IInternalMediaCallHistoryItem,
CallHistoryItemState,
IExternalMediaCallHistoryItem,
VideoConference,
AtLeast,
IGroupVideoConference,
IRegisterUser,
} from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall, ESCALATED_CALL_FEATURES } from '@rocket.chat/media-calls';
import type {
CallFeature,
ClientMediaSignal,
Expand All @@ -18,7 +22,7 @@ import type {
} from '@rocket.chat/media-signaling';
import { isClientMediaSignal } from '@rocket.chat/media-signaling';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
import { CallHistory, MediaCalls, Rooms, Users, VideoConference as VideoConferenceModel } from '@rocket.chat/models';
import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';

import { logger } from './logger';
Expand All @@ -42,7 +46,10 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));

this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
if (setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) {
if (
(setting._id.startsWith('VoIP_TeamCollab_') && !setting._id.includes('ExternalCallHistory')) ||
setting._id.startsWith('Pexip_Integration_SIP_')
) {
setImmediate(() => this.configureMediaCallServer());
}
});
Expand Down Expand Up @@ -429,23 +436,26 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
host: settings.get<string>('VoIP_TeamCollab_SIP_Server_Host') ?? '',
port: settings.get<number>('VoIP_TeamCollab_SIP_Server_Port') ?? 5060,
},
pexipServer: {
host: settings.get<string>('Pexip_Integration_SIP_Host') ?? '',
port: settings.get<number>('Pexip_Integration_SIP_Port') ?? 5060,
},
},
mobileRinging,
permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType),
isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature),
isFeatureEnabled: (feature) => this.isFeatureEnabled(feature),
};
}

private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean {
if (feature === 'audio') {
return true;
}

if (feature === 'screen-share') {
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
private isFeatureEnabled(feature: CallFeature): boolean {
switch (feature) {
case 'screen-share':
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
case 'conference-escalation':
return Boolean(settings.get('VoIP_TeamCollab_Video_Escalation_Enabled') && settings.get('Pexip_Integration_Enabled'));
default:
return true;
}

return true;
}

private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean> {
Expand All @@ -470,4 +480,208 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
throw err;
}
}

public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise<string> {
const { callId } = params;

logger.debug({ msg: 'Escalating Voice Call', method: 'MediaCallService.escalateCall', uid, callId });

const call = await MediaCalls.findOneById(callId);

try {
if (!call?.acceptedAt || call.ended) {
throw new Error('not-found');
}

if (!call.uids.includes(uid)) {
throw new Error('not-found');
}
if (!call.features.includes('conference-escalation')) {
throw new Error('feature-not-available');
}

const user = await Users.findOneById(uid);
if (!user) {
throw new Error('internal-error');
}

const url = await this.escalateVoiceCallToConference(user, call);

// If the peer has also escalated this call, then we can hangup as we join the conference
if (call.escalatedByPeerAt) {
void callServer.hangupEscalatedCall(call, { type: 'user', id: user._id }).catch((err) => {
logger.error({ msg: 'Unexpected error while hanging up a fully escalated voice call', err });
});
}

return url;
} catch (err) {
logger.debug({ msg: 'Unexpected error during escalation', err, uid, callId, call });
throw err;
}
}
Comment on lines +484 to +522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH Missing Permission and Room Access Validation in Voice Call Escalation

The REST API endpoint media-calls.escalate and its corresponding service method MediaCallService.escalateCall allow any authenticated user to escalate an active voice call to a video conference. However, the implementation lacks critical authorization checks:

  1. No Video Conference Permission Check: It does not verify if the escalating user has the necessary permissions to start or join video conferences (e.g., call-management).
  2. No Room Access Validation: When escalating external calls, the target room defaults to the persistent chat external room (Pexip_Integration_PersistentChat_ExternalRoom) configured in settings. The method never validates whether the user uid actually has access to this target room before creating the conference and returning a join URL.

This allows an attacker who is a participant in a voice call to bypass video conference permissions and potentially gain unauthorized access/join links to video conferences in restricted rooms.

Steps to Reproduce
  1. Establish an active voice call with the conference-escalation feature enabled.
  2. Send a POST request to /api/v1/media-calls.escalate with the callId of the call as an authenticated user who does not have video conference permissions or access to the persistent chat external room.
  3. Observe that the API successfully returns a conference join URL.
Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: apps/meteor/server/services/media-call/service.ts
Lines: 484-522
Severity: high

Vulnerability: Missing Permission and Room Access Validation in Voice Call Escalation

Description:
The REST API endpoint `media-calls.escalate` and its corresponding service method `MediaCallService.escalateCall` allow any authenticated user to escalate an active voice call to a video conference. However, the implementation lacks critical authorization checks:

1. **No Video Conference Permission Check**: It does not verify if the escalating user has the necessary permissions to start or join video conferences (e.g., `call-management`).
2. **No Room Access Validation**: When escalating external calls, the target room defaults to the persistent chat external room (`Pexip_Integration_PersistentChat_ExternalRoom`) configured in settings. The method never validates whether the user `uid` actually has access to this target room before creating the conference and returning a join URL.

This allows an attacker who is a participant in a voice call to bypass video conference permissions and potentially gain unauthorized access/join links to video conferences in restricted rooms.

Proof of Concept:
**Steps to Reproduce**

1. Establish an active voice call with the `conference-escalation` feature enabled.
2. Send a POST request to `/api/v1/media-calls.escalate` with the `callId` of the call as an authenticated user who does not have video conference permissions or access to the persistent chat external room.
3. Observe that the API successfully returns a conference join URL.

Affected Code:
	public async escalateCall(uid: IUser['_id'], params: { callId: string }): Promise<string> {
		const { callId } = params;

		logger.debug({ msg: 'Escalating Voice Call', method: 'MediaCallService.escalateCall', uid, callId });

		const call = await MediaCalls.findOneById(callId);

		try {
			if (!call?.acceptedAt || call.ended) {
				throw new Error('not-found');
			}

			if (!call.uids.includes(uid)) {
				throw new Error('not-found');
			}
			if (!call.features.includes('conference-escalation')) {
				throw new Error('feature-not-available');
			}

			const user = await Users.findOneById(uid);
			if (!user) {
				throw new Error('internal-error');
			}

			const url = await this.escalateVoiceCallToConference(user, call);

			// If the peer has also escalated this call, then we can hangup as we join the conference
			if (call.escalatedByPeerAt) {
				void callServer.hangupEscalatedCall(call, { type: 'user', id: user._id }).catch((err) => {
					logger.error({ msg: 'Unexpected error while hanging up a fully escalated voice call', err });
				});
			}

			return url;
		} catch (err) {
			logger.debug({ msg: 'Unexpected error during escalation', err, uid, callId, call });
			throw err;
		}
	}

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron


private async escalateVoiceCallToConference(user: IUser, call: IMediaCall): Promise<string> {
const conference = await this.getOrCreateConferenceForEscalatingCall(call, user);
if (conference?.type !== 'videoconference') {
logger.error({ msg: 'Failed to create conference for voice call escalation', type: conference?.type });
throw new Error('internal-error');
}

void this.flagAsEscalated(call).catch((err) => {
logger.error({ msg: 'Unexpected error while flagging call as escalated', err });
});

const result = await VideoConf.joinCall(conference, user, { mic: true, cam: false });

return result;
}

private async getOrCreateConferenceForEscalatingCall(call: IMediaCall, user: IUser): Promise<VideoConference | null> {
const existingConference = await VideoConferenceModel.findOneByMediaCallId(call._id);
if (existingConference) {
return existingConference;
}

// If the call is already flagged as escalated but no conference for it exists, don't create a new conference - some other process might still be running
if (call.escalatedAt) {
throw new Error('pre-escalated-conference-not-found');
}

return this.createConferenceForEscalatingCall(user, call);
}

private async createConferenceForEscalatingCall(user: IUser, call: IMediaCall): Promise<IGroupVideoConference | null> {
// TODO: ensure there are two legs with the same uid pair
const rid = await this.getRoomIdForExternalCall(call);
if (!rid) {
throw new Error('Could not find parent room to create the conference on');
}

return VideoConf.createEscalatedConference(
{
rid,
mediaCallIds: [call._id],
},
user as IRegisterUser,
);
}

private async getRoomIdForExternalCall(call: IMediaCall): Promise<string | null> {
const callerUid = call.caller.uid;
const calleeUid = call.callee.uid;

if (!callerUid || !calleeUid) {
return this.parsePersistentChatExternalRoom();
}

try {
const uids = [callerUid, calleeUid];
const uniqueUids = [...new Set(uids)];

const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids, { projection: { _id: 1 } });
if (room) {
return room._id;
}

const dmCreatorId = call.caller.type === 'user' ? callerUid : calleeUid;

const usernames = (
await Users.findByIds(uids, { projection: { username: 1 } })
.map((user) => user.username)
.toArray()
).filter((username) => username);

if (usernames.length !== 2) {
throw new Error('Invalid usernames for DM.');
}

const newRoom = await createDirectMessage(usernames, dmCreatorId, false);
return newRoom.rid;
} catch (err) {
logger.error({ msg: 'Failed to determine DM room for external call', err });
return this.parsePersistentChatExternalRoom();
}
}

private parsePersistentChatExternalRoom(): string | null {
const settingValue = settings.get('Pexip_Integration_PersistentChat_ExternalRoom');
if (!settingValue || typeof settingValue !== 'object' || !Array.isArray(settingValue) || !settingValue.length) {
return null;
}

for (const value of settingValue) {
if (!value || typeof value !== 'object' || !value._id) {
continue;
}

return value._id;
}

return null;
}

private async flagAsEscalated(call: IMediaCall): Promise<void> {
if (call.escalatedAt) {
return;
}

const updateResult = await MediaCalls.flagAsEscalatedByCallId(call._id);
if (!updateResult.modifiedCount) {
return;
}

await this.notifyEscalatedCall(call, Boolean(call.escalatedByPeerAt));
api.broadcast('media-call.updated', {
callId: call._id,
});
}

public async hangupAutoEscalatedCall(call: IMediaCall, uid: IUser['_id']): Promise<void> {
if (!call.escalatedByPeerAt) {
return this.flagAsEscalated(call);
}

if (!call.escalatedAt) {
await MediaCalls.flagAsEscalatedByCallId(call._id).catch((err) => {
logger.error({ msg: 'Unexpected error while flagging call as auto escalated', err });
});
}

await callServer.hangupEscalatedCall(call, { type: 'user', id: uid }).catch((err) => {
logger.error({ msg: 'Unexpected error while hanging up an auto escalated voice call', err });
});
}

private async notifyEscalatedCall(call: AtLeast<IMediaCall, '_id' | 'uids' | 'features'>, escalatedByPeer = false): Promise<void> {
for (const uid of call.uids) {
await this.sendSignal(uid, {
callId: call._id,
type: 'notification',
notification: 'escalated',
...(escalatedByPeer &&
call.features && {
features: call.features.filter((feature: any): feature is CallFeature => ESCALATED_CALL_FEATURES.includes(feature)),
}),
});
}
}

public async flagAsRemotelyEscalatedByCallId(callId: string): Promise<void> {
const call = await MediaCalls.findOneById(callId, { projection: { _id: 1, escalatedByPeerAt: 1, uids: 1, features: 1 } });
if (!call || call.escalatedByPeerAt) {
return;
}

const updateResult = await MediaCalls.flagAsRemotelyEscalatedByCallId(call._id);
if (!updateResult.modifiedCount) {
return;
}

if (!call.escalatedAt) {
await this.notifyEscalatedCall(call, true);
}

// TODO: maybe hangup if escalatedAt is already set?
}
}
Loading
Loading