mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-29 19:57:14 +00:00
🔒️(backend) validate Room configuration with Pydantic schema
Room.configuration accepted arbitrary JSON without validation, allowing unsafe or malformed payloads to be stored and creating a security risk. Define a Pydantic schema to enforce structure and constraints, and add validation at the serializer level to reject invalid inputs.
This commit is contained in:
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🔒️(backend) add validation of Room.configuration
|
||||||
|
|
||||||
## [1.15.0] - 2026-04-30
|
## [1.15.0] - 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
@@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||||
read_only_fields = ["id", "slug", "pin_code"]
|
read_only_fields = ["id", "slug", "pin_code"]
|
||||||
|
|
||||||
|
def validate_configuration(self, value):
|
||||||
|
"""Validate room configuration against the RoomConfiguration schema."""
|
||||||
|
if value is None or value == {}:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
RoomConfiguration.model_validate(value)
|
||||||
|
except PydanticValidationError as e:
|
||||||
|
raise serializers.ValidationError(e.errors()) from e
|
||||||
|
return value
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
"""
|
"""
|
||||||
Add users only for administrator users.
|
Add users only for administrator users.
|
||||||
@@ -306,6 +317,22 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RoomConfigurationTrackSource = Literal[
|
||||||
|
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RoomConfiguration(BaseModel):
|
||||||
|
"""Validate room configuration structure.
|
||||||
|
|
||||||
|
Unknown fields are rejected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class LobbyService:
|
|||||||
|
|
||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
room,
|
room: models.Room,
|
||||||
request,
|
request,
|
||||||
username: str,
|
username: str,
|
||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
|||||||
policy_parsed = urlparse(policy)
|
policy_parsed = urlparse(policy)
|
||||||
|
|
||||||
assert policy_parsed.scheme == "http"
|
assert policy_parsed.scheme == "http"
|
||||||
assert policy_parsed.netloc == "localhost:9000"
|
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||||
|
|
||||||
query_params = parse_qs(policy_parsed.query)
|
query_params = parse_qs(policy_parsed.query)
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"""
|
"""
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
access_level=RoomAccessLevel.PUBLIC,
|
access_level=RoomAccessLevel.PUBLIC,
|
||||||
configuration={"can_publish_sources": ["mock-source"]},
|
configuration={"can_publish_sources": ["camera"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
user=user,
|
user=user,
|
||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["mock-source"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
other_user = UserFactory()
|
other_user = UserFactory()
|
||||||
|
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
configuration={"can_publish_sources": ["mock-source"]},
|
configuration={"can_publish_sources": ["camera"]},
|
||||||
)
|
)
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||||
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
user=user,
|
user=user,
|
||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["mock-source"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def test_api_rooms_update_members():
|
|||||||
"name": "New name",
|
"name": "New name",
|
||||||
"slug": "should-be-ignored",
|
"slug": "should-be-ignored",
|
||||||
"access_level": RoomAccessLevel.RESTRICTED,
|
"access_level": RoomAccessLevel.RESTRICTED,
|
||||||
"configuration": {"the_key": "the_value"},
|
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
|
|||||||
"name": "New name",
|
"name": "New name",
|
||||||
"slug": "should-be-ignored",
|
"slug": "should-be-ignored",
|
||||||
"access_level": RoomAccessLevel.PUBLIC,
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"configuration": {"the_key": "the_value"},
|
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -104,7 +104,98 @@ def test_api_rooms_update_administrators():
|
|||||||
assert room.name == "New name"
|
assert room.name == "New name"
|
||||||
assert room.slug == "new-name"
|
assert room.slug == "new-name"
|
||||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||||
assert room.configuration == {"the_key": "the_value"}
|
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"configuration",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"can_publish_sources": ["camera", "microphone"]},
|
||||||
|
{
|
||||||
|
"can_publish_sources": [
|
||||||
|
"camera",
|
||||||
|
"microphone",
|
||||||
|
"screen_share",
|
||||||
|
"screen_share_audio",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"can_publish_sources": []},
|
||||||
|
{"can_publish_sources": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_rooms_update_configuration_valid(configuration):
|
||||||
|
"""Administrators should be allowed to set valid configurations."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id!s}/",
|
||||||
|
{"configuration": configuration},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == configuration
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_configuration_extra_keys_rejected():
|
||||||
|
"""Extra keys in configuration should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id!s}/",
|
||||||
|
{
|
||||||
|
"configuration": {
|
||||||
|
"can_publish_sources": ["camera"],
|
||||||
|
"arbitrary_key": "value",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
|
||||||
|
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
|
||||||
|
"""Invalid source values should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id!s}/",
|
||||||
|
{"configuration": {"can_publish_sources": [invalid_source]}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_configuration_wrong_type():
|
||||||
|
"""Configuration values with wrong types should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1.0/rooms/{room.id!s}/",
|
||||||
|
{"configuration": {"can_publish_sources": "camera"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.configuration == {}
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_update_administrators_of_another():
|
def test_api_rooms_update_administrators_of_another():
|
||||||
|
|||||||
Reference in New Issue
Block a user