🔒️(backend) sign lobby participant id

The participant id returned by request-entry was the raw LiveKit UUID,
so anyone knowing it could poll the lobby and obtain the admitted
participant's LiveKit token.

Return the id signed with SECRET_KEY under a lobby-specific salt, and
reject unsigned, tampered or foreign-signed ids with a 400. The raw
UUID is still used internally for the cache and LiveKit identity.
This commit is contained in:
lebaudantoine
2026-09-22 00:10:47 +02:00
parent 68b5db4c2d
commit b0bb39cc56
3 changed files with 169 additions and 18 deletions
+19 -3
View File
@@ -7,6 +7,7 @@ from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core import signing
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
@@ -356,11 +357,26 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
participant_id = serializers.UUIDField(required=False, allow_null=True)
participant_id = serializers.CharField(
required=False, allow_null=True, max_length=128
)
@staticmethod
def sign_participant_id(participant_id):
"""Sign with Django's SECRET_KEY and a lobby-specific namespace."""
return signing.Signer(salt="core.lobby.participant").sign(participant_id)
def validate_participant_id(self, value):
"""The id is a bearer credential: never trusted, only looked up."""
return str(value) if value else None
"""Require a valid server signature before looking up a participant."""
if value is None:
return None
try:
participant_id = signing.Signer(salt="core.lobby.participant").unsign(value)
except signing.BadSignature as exc:
raise serializers.ValidationError(
"Invalid participant credential."
) from exc
return str(serializers.UUIDField().run_validation(participant_id))
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
+9 -1
View File
@@ -541,7 +541,15 @@ class RoomViewSet(
user=request.user,
**serializer.validated_data,
)
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
return drf_response.Response(
{
**participant.to_dict(),
"id": serializers.RequestEntrySerializer.sign_participant_id(
participant.id
),
"livekit": livekit,
}
)
@decorators.action(
detail=True,
@@ -6,6 +6,7 @@ Test rooms API endpoints in the Meet core app: lobby functionality.
import uuid
from unittest import mock
from django.core import signing
from django.core.cache import cache
import pytest
@@ -20,6 +21,11 @@ from ...services.lobby import LobbyService
pytestmark = pytest.mark.django_db
def _lobby_signer():
"""Use the polling credential's dedicated signing namespace."""
return signing.Signer(salt="core.lobby.participant")
# Tests for request_entry endpoint
@@ -66,7 +72,8 @@ def test_request_entry_anonymous(settings):
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -115,7 +122,8 @@ def test_request_entry_authenticated_user(settings):
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -189,7 +197,8 @@ def test_request_entry_with_existing_participants(settings):
assert len(lobby_keys) == 3
# Verify the new participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
raw_id = _lobby_signer().unsign(participant_id)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{raw_id}")
assert participant_data.get("username") == "test_user"
@@ -207,7 +216,10 @@ def test_request_entry_public_room(settings):
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
mock.patch(
"core.services.lobby.uuid.uuid4",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
@@ -222,7 +234,7 @@ def test_request_entry_public_room(settings):
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
@@ -268,7 +280,7 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
@@ -313,7 +325,9 @@ def test_request_entry_waiting_participant_public_room(settings):
f"/api/v1.0/rooms/{room.id}/request-entry/",
{
"username": "user1",
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"participant_id": _lobby_signer().sign(
"2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
),
},
)
@@ -321,7 +335,7 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": _lobby_signer().sign("2f7f162f-e7d1-421b-90e7-02bfbfbf8def"),
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -671,7 +685,7 @@ def test_request_entry_throttling_anonymous_identified(
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
participant_id = _lobby_signer().sign(str(uuid.uuid4()))
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
@@ -763,7 +777,7 @@ def test_request_entry_with_participant_id(settings):
def test_request_entry_unknown_participant_id_not_seeded(settings):
"""An identifier unknown to the room's lobby must not be honored."""
"""A valid signed credential with no cached record creates a new participant."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
@@ -777,11 +791,14 @@ def test_request_entry_unknown_participant_id_not_seeded(settings):
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": forged_id},
{
"username": "test_user",
"participant_id": _lobby_signer().sign(forged_id),
},
)
assert response.status_code == 200
assert response.json()["id"] != forged_id
assert _lobby_signer().unsign(response.json()["id"]) != forged_id
# Nothing was stored under the forged identifier
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
@@ -832,11 +849,11 @@ def test_request_entry_legacy_cookie_ignored():
assert response.status_code == 200
returned_id = response.json()["id"]
assert returned_id != legacy_participant_id
uuid.UUID(returned_id)
uuid.UUID(_lobby_signer().unsign(returned_id))
def test_request_entry_malformed_participant_id(settings):
"""A non-UUID identifier is rejected by the serializer with a 400."""
"""A malformed polling credential is rejected with a 400."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
@@ -847,3 +864,113 @@ def test_request_entry_malformed_participant_id(settings):
assert response.status_code == 400
assert "participant_id" in response.json()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_unsigned_id(generate_config, _notify):
"""Knowing the public UUID must not grant admission."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
public_id = _lobby_signer().unsign(response.json()["id"])
LobbyService().handle_participant_entry(room.id, public_id, True)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": public_id},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_tampered_credential(generate_config, _notify):
"""Modifying a signed credential must invalidate it."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
credential = response.json()["id"]
public_id = _lobby_signer().unsign(credential)
LobbyService().handle_participant_entry(room.id, public_id, True)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": credential + "x"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(utils, "generate_livekit_config")
def test_request_entry_rejects_wrong_signing_secret(generate_config, _notify):
"""A credential signed with another secret must not grant admission."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
public_id = _lobby_signer().unsign(response.json()["id"])
LobbyService().handle_participant_entry(room.id, public_id, True)
forged = signing.Signer(
key="incorrect-test-signing-secret",
salt="core.lobby.participant",
fallback_keys=[],
).sign(public_id)
response = APIClient().post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Impersonator", "participant_id": forged},
)
assert response.status_code == 400
assert "participant_id" in response.json()
generate_config.assert_not_called()
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_accepts_signed_credential(generate_config, _notify):
"""The signed credential grants admission using the public LiveKit UUID."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", {"username": "Guest"}
)
assert response.status_code == 200
credential = response.json()["id"]
public_id = _lobby_signer().unsign(credential)
assert credential != public_id
LobbyService().handle_participant_entry(room.id, public_id, True)
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "Guest", "participant_id": credential},
)
assert response.status_code == 200
assert response.json()["status"] == "accepted"
assert response.json()["id"] == credential
assert response.json()["livekit"] == {"token": "test-token"}
generate_config.assert_called_once()
assert generate_config.call_args.kwargs["participant_id"] == public_id