From 385da867598c75695d2c935da024212cd43c68ca Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 17 May 2026 23:18:08 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(backend)=20verify=20parti?= =?UTF-8?q?cipant=20presence=20before=20mute=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure the participant requesting a mute action is still present in the room before processing the request. This mitigates scenarios where a previously issued token could be reused after the meeting has ended. Current token lifetime is intentionally long-lived and will be refactored in the future to better align with LiveKit session constraints. In the meantime, add this extra validation step to reduce the attack surface. --- src/backend/core/api/viewsets.py | 20 +++ .../core/services/participants_management.py | 42 ++++++ .../test_api_rooms_participants_management.py | 138 +++++++++++++++++- 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3c1e0c0e..c335ee28 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -668,6 +668,26 @@ class RoomViewSet( serializer = serializers.MuteParticipantSerializer(data=request.data) serializer.is_valid(raise_exception=True) + # TEMPORARY: a LiveKit token proves access was granted, not that the caller + # joined. Cross-check identity against the live participant list until auth + # is hardened. Skipped for non-LiveKit auth backends. + caller_identity = request.auth.identity if request.auth is not None else None + if caller_identity is not None: + try: + ParticipantsManagement().check_if_in_meeting( + room_name=str(room.pk), + identity=caller_identity, + ) + except (ParticipantNotFoundException, ParticipantsManagementException): + logger.warning( + "Failed to verify caller presence for mute in room %s; denying", + room.pk, + ) + return drf_response.Response( + {"error": "Could not verify caller presence"}, + status=drf_status.HTTP_403_FORBIDDEN, + ) + try: ParticipantsManagement().mute( room_name=str(room.pk), diff --git a/src/backend/core/services/participants_management.py b/src/backend/core/services/participants_management.py index 241d8f57..dfb51aa7 100644 --- a/src/backend/core/services/participants_management.py +++ b/src/backend/core/services/participants_management.py @@ -15,6 +15,7 @@ from livekit.api import ( TwirpError, UpdateParticipantRequest, ) +from livekit.protocol.models import ParticipantInfo from core import utils @@ -154,3 +155,44 @@ class ParticipantsManagement: finally: await lkapi.aclose() + + @async_to_sync + async def check_if_in_meeting(self, room_name: str, identity: str) -> bool: + """Check whether `identity` is currently a participant in `room_name`. + + Raises ParticipantsManagementException for unexpected LiveKit errors + so callers can fail closed rather than silently allowing the action. + """ + + if not room_name or not identity: + return False + + lkapi = utils.create_livekit_client() + + try: + participant = await lkapi.room.get_participant( + RoomParticipantIdentity( + room=room_name, + identity=identity, + ) + ) + except TwirpError as e: + if e.code == "not_found": + raise ParticipantNotFoundException("Participant does not exist") from e + + logger.exception( + "Unexpected error checking participant %s in room %s", + identity, + room_name, + ) + raise ParticipantsManagementException( + "Could not verify participant presence" + ) from e + + finally: + await lkapi.aclose() + + return ( + participant is not None + and participant.state != ParticipantInfo.State.DISCONNECTED + ) diff --git a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py index 0a9d1e35..08d8884e 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py +++ b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py @@ -2,7 +2,7 @@ Test rooms API endpoints in the Meet core app: participants management. """ -# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module +# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines import random from unittest import mock @@ -14,6 +14,7 @@ from django.urls import reverse import pytest from livekit.api import TwirpError, UpdateParticipantRequest +from livekit.protocol.models import ParticipantInfo from rest_framework import status from rest_framework.test import APIClient @@ -367,6 +368,141 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin( mock_livekit_client.room.mute_published_track.assert_not_called() +def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client): + """Should check participant presence when authenticated via LiveKit token only.""" + client = APIClient() + room = RoomFactory() + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + # Presence is verified against LiveKit before the mute is issued. + mock_livekit_client.room.get_participant.assert_called_once() + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_livekit_token_presence_check_returns_participant( + mock_livekit_client, +): + """Should mute when the authentified participant is currently in the room.""" + client = APIClient() + room = RoomFactory() + + # Simulate LiveKit confirming the caller is currently in the room. + # State != DISCONNECTED (3) means present. + mock_livekit_client.room.get_participant.return_value = ParticipantInfo( + identity="caller-identity", + state=ParticipantInfo.State.ACTIVE, + ) + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + mock_livekit_client.room.get_participant.assert_called_once() + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_livekit_token_presence_check_participant_not_found( + mock_livekit_client, +): + """Should not mute when the authentified participant is not found.""" + client = APIClient() + room = RoomFactory() + + mock_livekit_client.room.get_participant.side_effect = TwirpError( + msg="participant does not exist", code="not_found", status=404 + ) + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"error": "Could not verify caller presence"} + mock_livekit_client.room.get_participant.assert_called_once() + # The presence check failed, so we never reach the mute call. + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden( + mock_livekit_client, +): + """Should not mute when the presence check fail.""" + client = APIClient() + room = RoomFactory() + + mock_livekit_client.room.get_participant.side_effect = TwirpError( + msg="an error occured", code="not_found", status=500 + ) + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == {"error": "Could not verify caller presence"} + mock_livekit_client.room.get_participant.assert_called_once() + # The presence check failed, so we never reach the mute call. + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client): + """Should not check presence of the participant when authentified with a session cookie.""" + client = APIClient() + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.force_authenticate(user=user) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + # Session auth has no LiveKit identity to verify against, so the + # stop-gap presence check is skipped. + mock_livekit_client.room.get_participant.assert_not_called() + mock_livekit_client.room.mute_published_track.assert_called_once() + + def test_update_participant_success(mock_livekit_client): """Test successful participant update.""" client = APIClient()